diff --git a/.env b/.env
index 8c58d0d4..fbd02ad8 100644
--- a/.env
+++ b/.env
@@ -1 +1 @@
-VERSION=1.3.0
\ No newline at end of file
+VERSION=1.3.1
\ No newline at end of file
diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml
index 2d473e10..4a7cecd9 100644
--- a/.github/workflows/docker-image.yml
+++ b/.github/workflows/docker-image.yml
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
with:
fetch-depth: 1
@@ -37,7 +37,7 @@ jobs:
network=host
- name: Cache npm dependencies
- uses: actions/cache@v3
+ uses: actions/cache@v4
with:
path: |
~/.npm
@@ -48,7 +48,7 @@ jobs:
${{ runner.os }}-node-
- name: Cache Docker layers
- uses: actions/cache@v3
+ uses: actions/cache@v4
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.ref_name }}-${{ hashFiles('docker/Dockerfile') }}
@@ -78,7 +78,7 @@ jobs:
echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV
- name: Build and Push Multi-Arch Docker Image
- uses: docker/build-push-action@v5
+ uses: docker/build-push-action@v6
with:
context: .
file: ./docker/Dockerfile
diff --git a/docker/Dockerfile b/docker/Dockerfile
index c82aa3e6..15f3d81f 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -1,5 +1,5 @@
# Stage 1: Install dependencies and build frontend
-FROM node:22-alpine AS deps
+FROM node:24-alpine AS deps
WORKDIR /app
RUN apk add --no-cache python3 make g++
@@ -26,7 +26,7 @@ COPY . .
RUN npm run build:backend
# Stage 4: Production dependencies
-FROM node:22-alpine AS production-deps
+FROM node:24-alpine AS production-deps
WORKDIR /app
COPY package*.json ./
@@ -35,7 +35,7 @@ RUN npm ci --only=production --ignore-scripts --force && \
npm cache clean --force
# Stage 5: Build native modules
-FROM node:22-alpine AS native-builder
+FROM node:24-alpine AS native-builder
WORKDIR /app
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
# Stage 6: Final image
-FROM node:22-alpine
+FROM node:24-alpine
ENV DATA_DIR=/app/data \
PORT=8080 \
NODE_ENV=production
diff --git a/docker/nginx.conf b/docker/nginx.conf
index fe530ac4..728aad3b 100644
--- a/docker/nginx.conf
+++ b/docker/nginx.conf
@@ -71,6 +71,14 @@ http {
proxy_set_header Host $host;
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-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
@@ -85,7 +93,6 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
- # File manager recent, pinned, shortcuts (handled by SSH service)
location /ssh/file_manager/recent {
proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1;
@@ -113,7 +120,6 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
- # SSH file manager operations (handled by file manager service)
location /ssh/file_manager/ssh/ {
proxy_pass http://127.0.0.1:8084;
proxy_http_version 1.1;
diff --git a/package-lock.json b/package-lock.json
index dc2db2ae..23dc8262 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -72,27 +72,27 @@
"zod": "^4.0.5"
},
"devDependencies": {
- "@eslint/js": "^9.30.1",
+ "@eslint/js": "^9.34.0",
"@types/better-sqlite3": "^7.6.13",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/jsonwebtoken": "^9.0.10",
- "@types/node": "^24.0.13",
+ "@types/node": "^24.3.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/ssh2": "^1.15.5",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react-swc": "^3.10.2",
"autoprefixer": "^10.4.21",
- "eslint": "^9.30.1",
+ "eslint": "^9.34.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"ts-node": "^10.9.2",
"tw-animate-css": "^1.3.5",
- "typescript": "~5.8.3",
- "typescript-eslint": "^8.35.1",
- "vite": "^7.0.4"
+ "typescript": "~5.9.2",
+ "typescript-eslint": "^8.40.0",
+ "vite": "^7.1.3"
}
},
"node_modules/@ampproject/remapping": {
@@ -1023,9 +1023,9 @@
}
},
"node_modules/@eslint/config-helpers": {
- "version": "0.3.0",
- "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz",
- "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==",
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz",
+ "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
@@ -1033,9 +1033,9 @@
}
},
"node_modules/@eslint/core": {
- "version": "0.15.1",
- "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz",
- "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==",
+ "version": "0.15.2",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz",
+ "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -1083,9 +1083,9 @@
}
},
"node_modules/@eslint/js": {
- "version": "9.31.0",
- "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.31.0.tgz",
- "integrity": "sha512-LOm5OVt7D4qiKCqoiPbA7LWmI+tbw1VbTUowBcUMgQSuM6poJufkFkYDcQpo5KfgD39TnNySV26QjOh7VFpSyw==",
+ "version": "9.34.0",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.34.0.tgz",
+ "integrity": "sha512-EoyvqQnBNsV1CWaEJ559rxXL4c8V92gxirbawSmVUOWXlsRxxQXl6LmCpdUblgxgSkDIqKnhzba2SjRTI/A5Rw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1106,13 +1106,13 @@
}
},
"node_modules/@eslint/plugin-kit": {
- "version": "0.3.3",
- "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.3.tgz",
- "integrity": "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==",
+ "version": "0.3.5",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz",
+ "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@eslint/core": "^0.15.1",
+ "@eslint/core": "^0.15.2",
"levn": "^0.4.1"
},
"engines": {
@@ -3525,6 +3525,60 @@
"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": {
"version": "4.1.11",
"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": {
- "version": "24.0.13",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.13.tgz",
- "integrity": "sha512-Qm9OYVOFHFYg3wJoTSrz80hoec5Lia/dPp84do3X7dZvLikQvM1YpmvTBEdIr/e+U8HTkFjLHLnl78K/qjf+jQ==",
+ "version": "24.3.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz",
+ "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==",
"license": "MIT",
"dependencies": {
- "undici-types": "~7.8.0"
+ "undici-types": "~7.10.0"
}
},
"node_modules/@types/qs": {
@@ -3819,17 +3873,17 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
- "version": "8.37.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.37.0.tgz",
- "integrity": "sha512-jsuVWeIkb6ggzB+wPCsR4e6loj+rM72ohW6IBn2C+5NCvfUVY8s33iFPySSVXqtm5Hu29Ne/9bnA0JmyLmgenA==",
+ "version": "8.40.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.40.0.tgz",
+ "integrity": "sha512-w/EboPlBwnmOBtRbiOvzjD+wdiZdgFeo17lkltrtn7X37vagKKWJABvyfsJXTlHe6XBzugmYgd4A4nW+k8Mixw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.10.0",
- "@typescript-eslint/scope-manager": "8.37.0",
- "@typescript-eslint/type-utils": "8.37.0",
- "@typescript-eslint/utils": "8.37.0",
- "@typescript-eslint/visitor-keys": "8.37.0",
+ "@typescript-eslint/scope-manager": "8.40.0",
+ "@typescript-eslint/type-utils": "8.40.0",
+ "@typescript-eslint/utils": "8.40.0",
+ "@typescript-eslint/visitor-keys": "8.40.0",
"graphemer": "^1.4.0",
"ignore": "^7.0.0",
"natural-compare": "^1.4.0",
@@ -3843,9 +3897,9 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "@typescript-eslint/parser": "^8.37.0",
+ "@typescript-eslint/parser": "^8.40.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": {
@@ -3859,16 +3913,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
- "version": "8.37.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.37.0.tgz",
- "integrity": "sha512-kVIaQE9vrN9RLCQMQ3iyRlVJpTiDUY6woHGb30JDkfJErqrQEmtdWH3gV0PBAfGZgQXoqzXOO0T3K6ioApbbAA==",
+ "version": "8.40.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.40.0.tgz",
+ "integrity": "sha512-jCNyAuXx8dr5KJMkecGmZ8KI61KBUhkCob+SD+C+I5+Y1FWI2Y3QmY4/cxMCC5WAsZqoEtEETVhUiUMIGCf6Bw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/scope-manager": "8.37.0",
- "@typescript-eslint/types": "8.37.0",
- "@typescript-eslint/typescript-estree": "8.37.0",
- "@typescript-eslint/visitor-keys": "8.37.0",
+ "@typescript-eslint/scope-manager": "8.40.0",
+ "@typescript-eslint/types": "8.40.0",
+ "@typescript-eslint/typescript-estree": "8.40.0",
+ "@typescript-eslint/visitor-keys": "8.40.0",
"debug": "^4.3.4"
},
"engines": {
@@ -3880,18 +3934,18 @@
},
"peerDependencies": {
"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": {
- "version": "8.37.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.37.0.tgz",
- "integrity": "sha512-BIUXYsbkl5A1aJDdYJCBAo8rCEbAvdquQ8AnLb6z5Lp1u3x5PNgSSx9A/zqYc++Xnr/0DVpls8iQ2cJs/izTXA==",
+ "version": "8.40.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.40.0.tgz",
+ "integrity": "sha512-/A89vz7Wf5DEXsGVvcGdYKbVM9F7DyFXj52lNYUDS1L9yJfqjW/fIp5PgMuEJL/KeqVTe2QSbXAGUZljDUpArw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/tsconfig-utils": "^8.37.0",
- "@typescript-eslint/types": "^8.37.0",
+ "@typescript-eslint/tsconfig-utils": "^8.40.0",
+ "@typescript-eslint/types": "^8.40.0",
"debug": "^4.3.4"
},
"engines": {
@@ -3902,18 +3956,18 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "typescript": ">=4.8.4 <5.9.0"
+ "typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/scope-manager": {
- "version": "8.37.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.37.0.tgz",
- "integrity": "sha512-0vGq0yiU1gbjKob2q691ybTg9JX6ShiVXAAfm2jGf3q0hdP6/BruaFjL/ManAR/lj05AvYCH+5bbVo0VtzmjOA==",
+ "version": "8.40.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.40.0.tgz",
+ "integrity": "sha512-y9ObStCcdCiZKzwqsE8CcpyuVMwRouJbbSrNuThDpv16dFAj429IkM6LNb1dZ2m7hK5fHyzNcErZf7CEeKXR4w==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.37.0",
- "@typescript-eslint/visitor-keys": "8.37.0"
+ "@typescript-eslint/types": "8.40.0",
+ "@typescript-eslint/visitor-keys": "8.40.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3924,9 +3978,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
- "version": "8.37.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.37.0.tgz",
- "integrity": "sha512-1/YHvAVTimMM9mmlPvTec9NP4bobA1RkDbMydxG8omqwJJLEW/Iy2C4adsAESIXU3WGLXFHSZUU+C9EoFWl4Zg==",
+ "version": "8.40.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.40.0.tgz",
+ "integrity": "sha512-jtMytmUaG9d/9kqSl/W3E3xaWESo4hFDxAIHGVW/WKKtQhesnRIJSAJO6XckluuJ6KDB5woD1EiqknriCtAmcw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3937,19 +3991,19 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "typescript": ">=4.8.4 <5.9.0"
+ "typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/type-utils": {
- "version": "8.37.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.37.0.tgz",
- "integrity": "sha512-SPkXWIkVZxhgwSwVq9rqj/4VFo7MnWwVaRNznfQDc/xPYHjXnPfLWn+4L6FF1cAz6e7dsqBeMawgl7QjUMj4Ow==",
+ "version": "8.40.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.40.0.tgz",
+ "integrity": "sha512-eE60cK4KzAc6ZrzlJnflXdrMqOBaugeukWICO2rB0KNvwdIMaEaYiywwHMzA1qFpTxrLhN9Lp4E/00EgWcD3Ow==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.37.0",
- "@typescript-eslint/typescript-estree": "8.37.0",
- "@typescript-eslint/utils": "8.37.0",
+ "@typescript-eslint/types": "8.40.0",
+ "@typescript-eslint/typescript-estree": "8.40.0",
+ "@typescript-eslint/utils": "8.40.0",
"debug": "^4.3.4",
"ts-api-utils": "^2.1.0"
},
@@ -3962,13 +4016,13 @@
},
"peerDependencies": {
"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": {
- "version": "8.37.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.37.0.tgz",
- "integrity": "sha512-ax0nv7PUF9NOVPs+lmQ7yIE7IQmAf8LGcXbMvHX5Gm+YJUYNAl340XkGnrimxZ0elXyoQJuN5sbg6C4evKA4SQ==",
+ "version": "8.40.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.40.0.tgz",
+ "integrity": "sha512-ETdbFlgbAmXHyFPwqUIYrfc12ArvpBhEVgGAxVYSwli26dn8Ko+lIo4Su9vI9ykTZdJn+vJprs/0eZU0YMAEQg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3980,16 +4034,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
- "version": "8.37.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.37.0.tgz",
- "integrity": "sha512-zuWDMDuzMRbQOM+bHyU4/slw27bAUEcKSKKs3hcv2aNnc/tvE/h7w60dwVw8vnal2Pub6RT1T7BI8tFZ1fE+yg==",
+ "version": "8.40.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.40.0.tgz",
+ "integrity": "sha512-k1z9+GJReVVOkc1WfVKs1vBrR5MIKKbdAjDTPvIK3L8De6KbFfPFt6BKpdkdk7rZS2GtC/m6yI5MYX+UsuvVYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/project-service": "8.37.0",
- "@typescript-eslint/tsconfig-utils": "8.37.0",
- "@typescript-eslint/types": "8.37.0",
- "@typescript-eslint/visitor-keys": "8.37.0",
+ "@typescript-eslint/project-service": "8.40.0",
+ "@typescript-eslint/tsconfig-utils": "8.40.0",
+ "@typescript-eslint/types": "8.40.0",
+ "@typescript-eslint/visitor-keys": "8.40.0",
"debug": "^4.3.4",
"fast-glob": "^3.3.2",
"is-glob": "^4.0.3",
@@ -4005,7 +4059,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"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": {
@@ -4035,16 +4089,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
- "version": "8.37.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.37.0.tgz",
- "integrity": "sha512-TSFvkIW6gGjN2p6zbXo20FzCABbyUAuq6tBvNRGsKdsSQ6a7rnV6ADfZ7f4iI3lIiXc4F4WWvtUfDw9CJ9pO5A==",
+ "version": "8.40.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.40.0.tgz",
+ "integrity": "sha512-Cgzi2MXSZyAUOY+BFwGs17s7ad/7L+gKt6Y8rAVVWS+7o6wrjeFN4nVfTpbE25MNcxyJ+iYUXflbs2xR9h4UBg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.7.0",
- "@typescript-eslint/scope-manager": "8.37.0",
- "@typescript-eslint/types": "8.37.0",
- "@typescript-eslint/typescript-estree": "8.37.0"
+ "@typescript-eslint/scope-manager": "8.40.0",
+ "@typescript-eslint/types": "8.40.0",
+ "@typescript-eslint/typescript-estree": "8.40.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4055,17 +4109,17 @@
},
"peerDependencies": {
"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": {
- "version": "8.37.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.37.0.tgz",
- "integrity": "sha512-YzfhzcTnZVPiLfP/oeKtDp2evwvHLMe0LOy7oe+hb9KKIumLNohYS9Hgp1ifwpu42YWxhZE8yieggz6JpqO/1w==",
+ "version": "8.40.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.40.0.tgz",
+ "integrity": "sha512-8CZ47QwalyRjsypfwnbI3hKy5gJDPmrkLjkgMxhi0+DZZ2QNx2naS6/hWoVYUHU7LU2zleF68V9miaVZvhFfTA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.37.0",
+ "@typescript-eslint/types": "8.40.0",
"eslint-visitor-keys": "^4.2.1"
},
"engines": {
@@ -5402,20 +5456,20 @@
}
},
"node_modules/eslint": {
- "version": "9.31.0",
- "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.31.0.tgz",
- "integrity": "sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==",
+ "version": "9.34.0",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.34.0.tgz",
+ "integrity": "sha512-RNCHRX5EwdrESy3Jc9o8ie8Bog+PeYvvSR8sDGoZxNFTvZ4dlxUB3WzQ3bQMztFrSRODGrLLj8g6OFuGY/aiQg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
"@eslint/config-array": "^0.21.0",
- "@eslint/config-helpers": "^0.3.0",
- "@eslint/core": "^0.15.0",
+ "@eslint/config-helpers": "^0.3.1",
+ "@eslint/core": "^0.15.2",
"@eslint/eslintrc": "^3.3.1",
- "@eslint/js": "9.31.0",
- "@eslint/plugin-kit": "^0.3.1",
+ "@eslint/js": "9.34.0",
+ "@eslint/plugin-kit": "^0.3.5",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
@@ -8247,9 +8301,9 @@
"license": "MIT"
},
"node_modules/typescript": {
- "version": "5.8.3",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
- "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
+ "version": "5.9.2",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
+ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -8261,16 +8315,16 @@
}
},
"node_modules/typescript-eslint": {
- "version": "8.37.0",
- "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.37.0.tgz",
- "integrity": "sha512-TnbEjzkE9EmcO0Q2zM+GE8NQLItNAJpMmED1BdgoBMYNdqMhzlbqfdSwiRlAzEK2pA9UzVW0gzaaIzXWg2BjfA==",
+ "version": "8.40.0",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.40.0.tgz",
+ "integrity": "sha512-Xvd2l+ZmFDPEt4oj1QEXzA4A2uUK6opvKu3eGN9aGjB8au02lIVcLyi375w94hHyejTOmzIU77L8ol2sRg9n7Q==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/eslint-plugin": "8.37.0",
- "@typescript-eslint/parser": "8.37.0",
- "@typescript-eslint/typescript-estree": "8.37.0",
- "@typescript-eslint/utils": "8.37.0"
+ "@typescript-eslint/eslint-plugin": "8.40.0",
+ "@typescript-eslint/parser": "8.40.0",
+ "@typescript-eslint/typescript-estree": "8.40.0",
+ "@typescript-eslint/utils": "8.40.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -8281,13 +8335,13 @@
},
"peerDependencies": {
"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": {
- "version": "7.8.0",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
- "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==",
+ "version": "7.10.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz",
+ "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
"license": "MIT"
},
"node_modules/unpipe": {
@@ -8424,16 +8478,16 @@
}
},
"node_modules/vite": {
- "version": "7.0.4",
- "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.4.tgz",
- "integrity": "sha512-SkaSguuS7nnmV7mfJ8l81JGBFV7Gvzp8IzgE8A8t23+AxuNX61Q5H1Tpz5efduSN7NHC8nQXD3sKQKZAu5mNEA==",
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.3.tgz",
+ "integrity": "sha512-OOUi5zjkDxYrKhTV3V7iKsoS37VUM7v40+HuwEmcrsf11Cdx9y3DIr2Px6liIcZFwt3XSRpQvFpL3WVy7ApkGw==",
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
- "fdir": "^6.4.6",
- "picomatch": "^4.0.2",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
"postcss": "^8.5.6",
- "rollup": "^4.40.0",
+ "rollup": "^4.43.0",
"tinyglobby": "^0.2.14"
},
"bin": {
@@ -8498,10 +8552,13 @@
}
},
"node_modules/vite/node_modules/fdir": {
- "version": "6.4.6",
- "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
- "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
"peerDependencies": {
"picomatch": "^3 || ^4"
},
@@ -8512,9 +8569,9 @@
}
},
"node_modules/vite/node_modules/picomatch": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
- "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"engines": {
"node": ">=12"
diff --git a/package.json b/package.json
index e53653c2..b016dcb0 100644
--- a/package.json
+++ b/package.json
@@ -76,26 +76,26 @@
"zod": "^4.0.5"
},
"devDependencies": {
- "@eslint/js": "^9.30.1",
+ "@eslint/js": "^9.34.0",
"@types/better-sqlite3": "^7.6.13",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/jsonwebtoken": "^9.0.10",
- "@types/node": "^24.0.13",
+ "@types/node": "^24.3.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/ssh2": "^1.15.5",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react-swc": "^3.10.2",
"autoprefixer": "^10.4.21",
- "eslint": "^9.30.1",
+ "eslint": "^9.34.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"ts-node": "^10.9.2",
"tw-animate-css": "^1.3.5",
- "typescript": "~5.8.3",
- "typescript-eslint": "^8.35.1",
- "vite": "^7.0.4"
+ "typescript": "~5.9.2",
+ "typescript-eslint": "^8.40.0",
+ "vite": "^7.1.3"
}
}
diff --git a/src/App.tsx b/src/App.tsx
index cb2881da..fb3e1525 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -4,13 +4,10 @@ import {Homepage} from "@/ui/Homepage/Homepage.tsx"
import {AppView} from "@/ui/Navigation/AppView.tsx"
import {HostManager} from "@/ui/apps/Host Manager/HostManager.tsx"
import {TabProvider, useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx"
-import axios from "axios"
import {TopNavbar} from "@/ui/Navigation/TopNavbar.tsx";
import { AdminSettings } from "@/ui/Admin/AdminSettings";
import { Toaster } from "@/components/ui/sonner";
-
-const apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users";
-const API = axios.create({baseURL: apiBase});
+import { getUserInfo } from "@/ui/main-axios.ts";
function getCookie(name: string) {
return document.cookie.split('; ').reduce((r, v) => {
@@ -39,11 +36,11 @@ function AppContent() {
const jwt = getCookie("jwt");
if (jwt) {
setAuthLoading(true);
- API.get("/me", {headers: {Authorization: `Bearer ${jwt}`}})
+ getUserInfo()
.then((meRes) => {
setIsAuthenticated(true);
- setIsAdmin(!!meRes.data.is_admin);
- setUsername(meRes.data.username || null);
+ setIsAdmin(!!meRes.is_admin);
+ setUsername(meRes.username || null);
})
.catch((err) => {
setIsAuthenticated(false);
@@ -93,31 +90,19 @@ function AppContent() {
return (
{!isAuthenticated && !authLoading && (
-
-
-
-
-
-
+
)}
diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts
index 418e6dfd..186b8ff4 100644
--- a/src/backend/database/db/index.ts
+++ b/src/backend/database/db/index.ts
@@ -405,7 +405,6 @@ const migrateSchema = () => {
addColumnIfNotExists('users', 'token_url', 'TEXT');
try {
sqlite.prepare(`ALTER TABLE users DROP COLUMN redirect_uri`).run();
- logger.info('Removed redirect_uri column from users table');
} catch (e) {
}
diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts
index 790ac7be..d43aa630 100644
--- a/src/backend/database/routes/users.ts
+++ b/src/backend/database/routes/users.ts
@@ -79,7 +79,12 @@ async function verifyOIDCToken(idToken: string, issuerUrl: string, clientId: str
const key = await importJWK(publicKey);
const {payload} = await jwtVerify(idToken, key, {
- issuer: issuerUrl,
+ issuer: [
+ issuerUrl,
+ normalizedIssuerUrl,
+ issuerUrl.replace(/\/application\/o\/[^\/]+$/, ''),
+ normalizedIssuerUrl.replace(/\/application\/o\/[^\/]+$/, '')
+ ],
audience: clientId,
});
diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts
index 5f24f1f8..ee8bcc9e 100644
--- a/src/backend/ssh/file-manager.ts
+++ b/src/backend/ssh/file-manager.ts
@@ -48,7 +48,6 @@ interface SSHSession {
}
const sshSessions: Record
= {};
-const SESSION_TIMEOUT_MS = 10 * 60 * 1000;
function cleanupSession(sessionId: string) {
const session = sshSessions[sessionId];
@@ -66,25 +65,26 @@ function scheduleSessionCleanup(sessionId: string) {
const session = sshSessions[sessionId];
if (session) {
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;
if (!sessionId || !ip || !username || !port) {
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 config: any = {
host: ip,
port: port || 22,
username,
- readyTimeout: 20000,
- keepaliveInterval: 10000,
- keepaliveCountMax: 3,
+ readyTimeout: 0,
+ keepaliveInterval: 30000,
+ keepaliveCountMax: 0,
algorithms: {
kex: [
'diffie-hellman-group14-sha256',
@@ -122,8 +122,22 @@ app.post('/ssh/file_manager/ssh/connect', (req, res) => {
};
if (sshKey && sshKey.trim()) {
- config.privateKey = sshKey;
- if (keyPassword) config.passphrase = keyPassword;
+ 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;
+
+ 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()) {
config.password = password;
} else {
@@ -136,7 +150,6 @@ app.post('/ssh/file_manager/ssh/connect', (req, res) => {
if (responseSent) return;
responseSent = true;
sshSessions[sessionId] = {client, isConnected: true, lastActive: Date.now()};
- scheduleSessionCleanup(sessionId);
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();
- scheduleSessionCleanup(sessionId);
+
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
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();
- scheduleSessionCleanup(sessionId);
+
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
sshConn.client.exec(`cat '${escapedPath}'`, (err, stream) => {
@@ -303,14 +316,6 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
}
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 = () => {
try {
@@ -331,7 +336,6 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
fileBuffer = Buffer.from(content);
}
} catch (bufferErr) {
- clearTimeout(commandTimeout);
logger.error('Buffer conversion error:', bufferErr);
if (!res.headersSent) {
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', () => {
if (hasError || hasFinished) return;
hasFinished = true;
- clearTimeout(commandTimeout);
logger.success(`File written successfully via SFTP: ${filePath}`);
if (!res.headersSent) {
res.json({message: 'File written successfully', path: filePath});
@@ -364,7 +367,6 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
writeStream.on('close', () => {
if (hasError || hasFinished) return;
hasFinished = true;
- clearTimeout(commandTimeout);
logger.success(`File written successfully via SFTP: ${filePath}`);
if (!res.headersSent) {
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) => {
if (err) {
- clearTimeout(commandTimeout);
+
logger.error('Fallback write command failed:', err);
if (!res.headersSent) {
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) => {
- clearTimeout(commandTimeout);
+
if (outputData.includes('SUCCESS')) {
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) => {
- clearTimeout(commandTimeout);
+
logger.error('Fallback write stream error:', streamErr);
if (!res.headersSent) {
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) {
- clearTimeout(commandTimeout);
+
logger.error('Fallback method failed:', fallbackErr);
if (!res.headersSent) {
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();
- scheduleSessionCleanup(sessionId);
+
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 = () => {
try {
@@ -498,7 +495,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
fileBuffer = Buffer.from(content);
}
} catch (bufferErr) {
- clearTimeout(commandTimeout);
+
logger.error('Buffer conversion error:', bufferErr);
if (!res.headersSent) {
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', () => {
if (hasError || hasFinished) return;
hasFinished = true;
- clearTimeout(commandTimeout);
+
logger.success(`File uploaded successfully via SFTP: ${fullPath}`);
if (!res.headersSent) {
res.json({message: 'File uploaded successfully', path: fullPath});
@@ -531,7 +528,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
writeStream.on('close', () => {
if (hasError || hasFinished) return;
hasFinished = true;
- clearTimeout(commandTimeout);
+
logger.success(`File uploaded successfully via SFTP: ${fullPath}`);
if (!res.headersSent) {
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) => {
if (err) {
- clearTimeout(commandTimeout);
+
logger.error('Fallback upload command failed:', err);
if (!res.headersSent) {
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) => {
- clearTimeout(commandTimeout);
+
if (outputData.includes('SUCCESS')) {
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) => {
- clearTimeout(commandTimeout);
+
logger.error('Fallback upload stream error:', streamErr);
if (!res.headersSent) {
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) => {
if (err) {
- clearTimeout(commandTimeout);
+
logger.error('Chunked fallback upload failed:', err);
if (!res.headersSent) {
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) => {
- clearTimeout(commandTimeout);
+
if (outputData.includes('SUCCESS')) {
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) => {
- clearTimeout(commandTimeout);
logger.error('Chunked fallback upload stream error:', streamErr);
if (!res.headersSent) {
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) {
- clearTimeout(commandTimeout);
logger.error('Fallback method failed:', fallbackErr);
if (!res.headersSent) {
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();
- scheduleSessionCleanup(sessionId);
const fullPath = filePath.endsWith('/') ? filePath + fileName : filePath + '/' + fileName;
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`;
sshConn.client.exec(createCommand, (err, stream) => {
if (err) {
- clearTimeout(commandTimeout);
logger.error('SSH createFile error:', err);
if (!res.headersSent) {
return res.status(500).json({error: err.message});
@@ -739,7 +725,6 @@ app.post('/ssh/file_manager/ssh/createFile', (req, res) => {
errorData += chunk.toString();
if (chunk.toString().includes('Permission denied')) {
- clearTimeout(commandTimeout);
logger.error(`Permission denied creating file: ${fullPath}`);
if (!res.headersSent) {
return res.status(403).json({
@@ -751,8 +736,6 @@ app.post('/ssh/file_manager/ssh/createFile', (req, res) => {
});
stream.on('close', (code) => {
- clearTimeout(commandTimeout);
-
if (outputData.includes('SUCCESS')) {
if (!res.headersSent) {
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) => {
- clearTimeout(commandTimeout);
logger.error('SSH createFile stream error:', streamErr);
if (!res.headersSent) {
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();
- scheduleSessionCleanup(sessionId);
const fullPath = folderPath.endsWith('/') ? folderPath + folderName : folderPath + '/' + folderName;
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`;
sshConn.client.exec(createCommand, (err, stream) => {
if (err) {
- clearTimeout(commandTimeout);
+
logger.error('SSH createFolder error:', err);
if (!res.headersSent) {
return res.status(500).json({error: err.message});
@@ -835,7 +809,6 @@ app.post('/ssh/file_manager/ssh/createFolder', (req, res) => {
errorData += chunk.toString();
if (chunk.toString().includes('Permission denied')) {
- clearTimeout(commandTimeout);
logger.error(`Permission denied creating folder: ${fullPath}`);
if (!res.headersSent) {
return res.status(403).json({
@@ -847,8 +820,6 @@ app.post('/ssh/file_manager/ssh/createFolder', (req, res) => {
});
stream.on('close', (code) => {
- clearTimeout(commandTimeout);
-
if (outputData.includes('SUCCESS')) {
if (!res.headersSent) {
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) => {
- clearTimeout(commandTimeout);
logger.error('SSH createFolder stream error:', streamErr);
if (!res.headersSent) {
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();
- scheduleSessionCleanup(sessionId);
-
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
? `rm -rf '${escapedPath}' && echo "SUCCESS" && exit 0`
: `rm -f '${escapedPath}' && echo "SUCCESS" && exit 0`;
sshConn.client.exec(deleteCommand, (err, stream) => {
if (err) {
- clearTimeout(commandTimeout);
logger.error('SSH deleteItem error:', err);
if (!res.headersSent) {
return res.status(500).json({error: err.message});
@@ -932,7 +892,6 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => {
errorData += chunk.toString();
if (chunk.toString().includes('Permission denied')) {
- clearTimeout(commandTimeout);
logger.error(`Permission denied deleting: ${itemPath}`);
if (!res.headersSent) {
return res.status(403).json({
@@ -944,8 +903,6 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => {
});
stream.on('close', (code) => {
- clearTimeout(commandTimeout);
-
if (outputData.includes('SUCCESS')) {
if (!res.headersSent) {
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) => {
- clearTimeout(commandTimeout);
logger.error('SSH deleteItem stream error:', streamErr);
if (!res.headersSent) {
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();
- scheduleSessionCleanup(sessionId);
const oldDir = oldPath.substring(0, oldPath.lastIndexOf('/') + 1);
const newPath = oldDir + newName;
const escapedOldPath = oldPath.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`;
sshConn.client.exec(renameCommand, (err, stream) => {
if (err) {
- clearTimeout(commandTimeout);
logger.error('SSH renameItem error:', err);
if (!res.headersSent) {
return res.status(500).json({error: err.message});
@@ -1030,7 +977,6 @@ app.put('/ssh/file_manager/ssh/renameItem', (req, res) => {
errorData += chunk.toString();
if (chunk.toString().includes('Permission denied')) {
- clearTimeout(commandTimeout);
logger.error(`Permission denied renaming: ${oldPath}`);
if (!res.headersSent) {
return res.status(403).json({
@@ -1042,8 +988,6 @@ app.put('/ssh/file_manager/ssh/renameItem', (req, res) => {
});
stream.on('close', (code) => {
- clearTimeout(commandTimeout);
-
if (outputData.includes('SUCCESS')) {
if (!res.headersSent) {
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) => {
- clearTimeout(commandTimeout);
logger.error('SSH renameItem stream error:', streamErr);
if (!res.headersSent) {
res.status(500).json({error: `Stream error: ${streamErr.message}`});
diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts
index 2fd66bd1..8fb6c050 100644
--- a/src/backend/ssh/server-stats.ts
+++ b/src/backend/ssh/server-stats.ts
@@ -115,10 +115,27 @@ function buildSshConfig(host: HostRecord): ConnectConfig {
(base as any).password = host.password || '';
} else if (host.authType === 'key') {
if (host.key) {
- (base as any).privateKey = Buffer.from(host.key, 'utf8');
- }
- if (host.keyPassword) {
- (base as any).passphrase = host.keyPassword;
+ 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) {
+ (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;
@@ -278,15 +295,27 @@ async function collectMetrics(host: HostRecord): Promise<{
let usedHuman: string | null = null;
let totalHuman: string | null = null;
try {
- const diskOut = await execCommand(client, 'df -h -P / | tail -n +2');
- const line = diskOut.stdout.split('\n').map(l => l.trim()).filter(Boolean)[0] || '';
- const parts = line.split(/\s+/);
- if (parts.length >= 6) {
- totalHuman = parts[1] || null;
- usedHuman = parts[2] || null;
- const pctStr = (parts[4] || '').replace('%', '');
- const pctNum = Number(pctStr);
- diskPercent = Number.isFinite(pctNum) ? pctNum : null;
+ // Get both human-readable and bytes format for accurate calculation
+ const diskOutHuman = await execCommand(client, 'df -h -P / | tail -n +2');
+ const diskOutBytes = await execCommand(client, 'df -B1 -P / | tail -n +2');
+
+ const humanLine = diskOutHuman.stdout.split('\n').map(l => l.trim()).filter(Boolean)[0] || '';
+ const bytesLine = diskOutBytes.stdout.split('\n').map(l => l.trim()).filter(Boolean)[0] || '';
+
+ const humanParts = humanLine.split(/\s+/);
+ 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) {
diskPercent = null;
@@ -413,9 +442,4 @@ app.listen(PORT, async () => {
} catch (err) {
logger.error('Initial poll failed', err);
}
-});
-
-setInterval(() => {
- pollStatusesOnce().catch(err => logger.error('Background poll failed', err));
-}, 60_000);
-
+});
\ No newline at end of file
diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts
index 4646528c..7b23cac0 100644
--- a/src/backend/ssh/terminal.ts
+++ b/src/backend/ssh/terminal.ts
@@ -4,6 +4,9 @@ import chalk from 'chalk';
const wss = new WebSocketServer({port: 8082});
+
+
+
const sshIconSymbol = '🖥️';
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
@@ -30,16 +33,22 @@ const logger = {
}
};
+
+
wss.on('connection', (ws: WebSocket) => {
let sshConn: Client | null = null;
let sshStream: ClientChannel | null = null;
let pingInterval: NodeJS.Timeout | null = null;
+
+
ws.on('close', () => {
cleanupSSH();
});
ws.on('message', (msg: RawData) => {
+
+
let parsed: any;
try {
parsed = JSON.parse(msg.toString());
@@ -132,34 +141,13 @@ wss.on('connection', (ws: WebSocket) => {
sshConn.on('ready', () => {
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) {
logger.error('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;
- stream.on('data', (chunk: Buffer) => {
- let data: string;
- try {
- data = chunk.toString('utf8');
- } catch (e) {
- data = chunk.toString('binary');
- }
-
- ws.send(JSON.stringify({type: 'data', data}));
+ stream.on('data', (data: Buffer) => {
+ ws.send(JSON.stringify({type: 'data', data: data.toString()}));
});
stream.on('close', () => {
- cleanupSSH(connectionTimeout);
+
+ ws.send(JSON.stringify({type: 'disconnected', message: 'Connection lost'}));
});
stream.on('error', (err: Error) => {
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();
@@ -233,9 +205,12 @@ wss.on('connection', (ws: WebSocket) => {
sshConn.on('close', () => {
clearTimeout(connectionTimeout);
+
cleanupSSH(connectionTimeout);
});
+
+
const connectConfig: any = {
host: ip,
port,
@@ -245,6 +220,7 @@ wss.on('connection', (ws: WebSocket) => {
readyTimeout: 10000,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
+
env: {
TERM: 'xterm-256color',
LANG: 'en_US.UTF-8',
@@ -294,12 +270,26 @@ wss.on('connection', (ws: WebSocket) => {
}
};
if (authType === 'key' && key) {
- connectConfig.privateKey = key;
- if (keyPassword) {
- connectConfig.passphrase = keyPassword;
- }
- if (keyType && keyType !== 'auto') {
- connectConfig.privateKeyType = keyType;
+ 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) {
+ connectConfig.passphrase = keyPassword;
+ }
+
+ if (keyType && keyType !== 'auto') {
+ connectConfig.privateKeyType = keyType;
+ }
+ } catch (keyError) {
+ logger.error('SSH key format error: ' + keyError.message);
+ ws.send(JSON.stringify({type: 'error', message: 'SSH key format error: Invalid private key format'}));
+ return;
}
} else if (authType === 'key') {
logger.error('SSH key authentication requested but no key provided');
@@ -360,4 +350,6 @@ wss.on('connection', (ws: WebSocket) => {
}
}, 60000);
}
+
+
});
diff --git a/src/backend/ssh/tunnel.ts b/src/backend/ssh/tunnel.ts
index 6530c921..274af443 100644
--- a/src/backend/ssh/tunnel.ts
+++ b/src/backend/ssh/tunnel.ts
@@ -197,7 +197,8 @@ function classifyError(errorMessage: string): ErrorType {
if (message.includes("connect etimedout") ||
message.includes("timeout") ||
- message.includes("timed out")) {
+ message.includes("timed out") ||
+ message.includes("keepalive timeout")) {
return ERROR_TYPES.TIMEOUT;
}
@@ -267,7 +268,8 @@ function cleanupTunnelResources(tunnelName: string): void {
tunnelName,
`${tunnelName}_confirm`,
`${tunnelName}_retry`,
- `${tunnelName}_verify_retry`
+ `${tunnelName}_verify_retry`,
+ `${tunnelName}_ping`
];
timerKeys.forEach(key => {
@@ -302,7 +304,7 @@ function resetRetryState(tunnelName: string): void {
countdownIntervals.delete(tunnelName);
}
- ['', '_confirm', '_retry', '_verify_retry'].forEach(suffix => {
+ ['', '_confirm', '_retry', '_verify_retry', '_ping'].forEach(suffix => {
const timerKey = `${tunnelName}${suffix}`;
if (verificationTimers.has(timerKey)) {
clearTimeout(verificationTimers.get(timerKey)!);
@@ -353,7 +355,8 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
const maxRetries = tunnelConfig.maxRetries || 3;
const retryInterval = tunnelConfig.retryInterval || 5000;
- let retryCount = (retryCounters.get(tunnelName) || 0) + 1;
+ let retryCount = retryCounters.get(tunnelName) || 0;
+ retryCount = retryCount + 1;
if (retryCount > maxRetries) {
logger.error(`All ${maxRetries} retries failed for ${tunnelName}`);
@@ -420,7 +423,6 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
if (!manualDisconnects.has(tunnelName)) {
activeTunnels.delete(tunnelName);
-
connectSSHTunnel(tunnelConfig, retryCount);
}
}, retryInterval);
@@ -438,264 +440,43 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
}
function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig, isPeriodic = false): void {
- if (manualDisconnects.has(tunnelName) || !activeTunnels.has(tunnelName)) {
- return;
- }
-
- if (tunnelVerifications.has(tunnelName)) {
- return;
- }
-
- const conn = activeTunnels.get(tunnelName);
- if (!conn) return;
-
- broadcastTunnelStatus(tunnelName, {
- connected: false,
- status: CONNECTION_STATES.VERIFYING
- });
-
- const verificationConn = new Client();
- tunnelVerifications.set(tunnelName, {
- conn: verificationConn,
- timeout: setTimeout(() => {
- logger.error(`Verification timeout for '${tunnelName}'`);
- cleanupVerification(false, "Verification timeout");
- }, 10000)
- });
-
- function cleanupVerification(isSuccessful: boolean, failureReason = "Unknown verification failure") {
- const verification = tunnelVerifications.get(tunnelName);
- if (verification) {
- clearTimeout(verification.timeout);
- try {
- verification.conn.end();
- } catch (e) {
- }
- tunnelVerifications.delete(tunnelName);
- }
-
- if (isSuccessful) {
+ if (isPeriodic) {
+ if (!activeTunnels.has(tunnelName)) {
broadcastTunnelStatus(tunnelName, {
- connected: true,
- status: CONNECTION_STATES.CONNECTED
+ connected: false,
+ status: CONNECTION_STATES.DISCONNECTED,
+ reason: 'Tunnel connection lost'
});
-
- if (!isPeriodic) {
- setupPingInterval(tunnelName, tunnelConfig);
- }
- } else {
- logger.warn(`Verification failed for '${tunnelName}': ${failureReason}`);
-
- if (failureReason.includes('command failed') || failureReason.includes('connection error') || failureReason.includes('timeout')) {
- if (!manualDisconnects.has(tunnelName)) {
- broadcastTunnelStatus(tunnelName, {
- connected: false,
- status: CONNECTION_STATES.FAILED,
- reason: failureReason
- });
- }
- activeTunnels.delete(tunnelName);
- handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
- } else {
- logger.info(`Assuming tunnel '${tunnelName}' is working despite verification warning: ${failureReason}`);
- cleanupVerification(true);
- }
}
}
-
- function attemptVerification() {
- const testCmd = `timeout 3 bash -c 'nc -z ${tunnelConfig.endpointIP} ${tunnelConfig.endpointPort}'`;
-
- verificationConn.exec(testCmd, (err, stream) => {
- if (err) {
- logger.error(`Verification command failed for '${tunnelName}': ${err.message}`);
- cleanupVerification(false, `Verification command failed: ${err.message}`);
- return;
- }
-
- let output = '';
- let errorOutput = '';
-
- stream.on('data', (data: Buffer) => {
- output += data.toString();
- });
-
- stream.stderr?.on('data', (data: Buffer) => {
- errorOutput += data.toString();
- });
-
- stream.on('close', (code: number) => {
- if (code === 0) {
- cleanupVerification(true);
- } else {
- const isTimeout = errorOutput.includes('timeout') || errorOutput.includes('Connection timed out');
- const isConnectionRefused = errorOutput.includes('Connection refused') || errorOutput.includes('No route to host');
-
- let failureReason = `Cannot connect to ${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort}`;
- if (isTimeout) {
- failureReason = `Tunnel verification timeout - cannot reach ${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort}`;
- } else if (isConnectionRefused) {
- failureReason = `Connection refused to ${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort} - tunnel may not be established`;
- }
-
- cleanupVerification(false, failureReason);
- }
- });
-
- stream.on('error', (err: Error) => {
- logger.error(`Verification stream error for '${tunnelName}': ${err.message}`);
- cleanupVerification(false, `Verification stream error: ${err.message}`);
- });
- });
- }
-
- verificationConn.on('ready', () => {
- setTimeout(() => {
- attemptVerification();
- }, 2000);
- });
-
- verificationConn.on('error', (err: Error) => {
- cleanupVerification(false, `Verification connection error: ${err.message}`);
- });
-
- verificationConn.on('close', () => {
- if (tunnelVerifications.has(tunnelName)) {
- cleanupVerification(false, "Verification connection closed");
- }
- });
-
- const connOptions: any = {
- host: tunnelConfig.sourceIP,
- port: tunnelConfig.sourceSSHPort,
- username: tunnelConfig.sourceUsername,
- readyTimeout: 10000,
- keepaliveInterval: 30000,
- keepaliveCountMax: 3,
- tcpKeepAlive: true,
- tcpKeepAliveInitialDelay: 30000,
- algorithms: {
- kex: [
- 'diffie-hellman-group14-sha256',
- 'diffie-hellman-group14-sha1',
- 'diffie-hellman-group1-sha1',
- 'diffie-hellman-group-exchange-sha256',
- 'diffie-hellman-group-exchange-sha1',
- 'ecdh-sha2-nistp256',
- 'ecdh-sha2-nistp384',
- 'ecdh-sha2-nistp521'
- ],
- cipher: [
- 'aes128-ctr',
- 'aes192-ctr',
- 'aes256-ctr',
- 'aes128-gcm@openssh.com',
- 'aes256-gcm@openssh.com',
- 'aes128-cbc',
- 'aes192-cbc',
- 'aes256-cbc',
- '3des-cbc'
- ],
- hmac: [
- 'hmac-sha2-256',
- 'hmac-sha2-512',
- 'hmac-sha1',
- 'hmac-md5'
- ],
- compress: [
- 'none',
- 'zlib@openssh.com',
- 'zlib'
- ]
- }
- };
-
- if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
- connOptions.privateKey = tunnelConfig.sourceSSHKey;
- if (tunnelConfig.sourceKeyPassword) {
- connOptions.passphrase = tunnelConfig.sourceKeyPassword;
- }
- if (tunnelConfig.sourceKeyType && tunnelConfig.sourceKeyType !== 'auto') {
- connOptions.privateKeyType = tunnelConfig.sourceKeyType;
- }
- } else if (tunnelConfig.sourceAuthMethod === "key") {
- logger.error(`SSH key authentication requested but no key provided for tunnel '${tunnelName}'`);
- broadcastTunnelStatus(tunnelName, {
- connected: false,
- status: CONNECTION_STATES.FAILED,
- reason: "SSH key authentication requested but no key provided"
- });
- return;
- } else {
- connOptions.password = tunnelConfig.sourcePassword;
- }
-
- verificationConn.connect(connOptions);
}
function setupPingInterval(tunnelName: string, tunnelConfig: TunnelConfig): void {
+ const pingKey = `${tunnelName}_ping`;
+ if (verificationTimers.has(pingKey)) {
+ clearInterval(verificationTimers.get(pingKey)!);
+ verificationTimers.delete(pingKey);
+ }
+
const pingInterval = setInterval(() => {
- if (!activeTunnels.has(tunnelName) || manualDisconnects.has(tunnelName)) {
- clearInterval(pingInterval);
- return;
- }
-
- const conn = activeTunnels.get(tunnelName);
- if (!conn) {
- clearInterval(pingInterval);
- return;
- }
-
- conn.exec('echo "ping"', (err, stream) => {
- if (err) {
+ const currentStatus = connectionStatus.get(tunnelName);
+ if (currentStatus?.status === CONNECTION_STATES.CONNECTED) {
+ if (!activeTunnels.has(tunnelName)) {
+ broadcastTunnelStatus(tunnelName, {
+ connected: false,
+ status: CONNECTION_STATES.DISCONNECTED,
+ reason: 'Tunnel connection lost'
+ });
clearInterval(pingInterval);
-
- if (!manualDisconnects.has(tunnelName)) {
- broadcastTunnelStatus(tunnelName, {
- connected: false,
- status: CONNECTION_STATES.UNSTABLE,
- reason: "Ping failed"
- });
- }
-
- activeTunnels.delete(tunnelName);
- handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
- return;
+ verificationTimers.delete(pingKey);
}
-
- stream.on('close', (code: number) => {
- if (code !== 0) {
- clearInterval(pingInterval);
-
- if (!manualDisconnects.has(tunnelName)) {
- broadcastTunnelStatus(tunnelName, {
- connected: false,
- status: CONNECTION_STATES.UNSTABLE,
- reason: "Ping command failed"
- });
- }
-
- activeTunnels.delete(tunnelName);
- handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
- }
- });
-
- stream.on('error', (err: Error) => {
- clearInterval(pingInterval);
-
- if (!manualDisconnects.has(tunnelName)) {
- broadcastTunnelStatus(tunnelName, {
- connected: false,
- status: CONNECTION_STATES.UNSTABLE,
- reason: "Ping stream error"
- });
- }
-
- activeTunnels.delete(tunnelName);
- handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
- });
- });
- }, 60000);
+ } else {
+ clearInterval(pingInterval);
+ verificationTimers.delete(pingKey);
+ }
+ }, 120000);
+
+ verificationTimers.set(pingKey, pingInterval);
}
function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
@@ -751,7 +532,7 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
}
}
- }, 15000);
+ }, 60000);
conn.on("error", (err) => {
clearTimeout(connectionTimeout);
@@ -778,6 +559,8 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
errorType === ERROR_TYPES.PORT ||
errorType === ERROR_TYPES.PERMISSION ||
manualDisconnects.has(tunnelName);
+
+
handleDisconnect(tunnelName, tunnelConfig, !shouldNotRetry);
});
@@ -841,7 +624,11 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
setTimeout(() => {
if (!manualDisconnects.has(tunnelName) && activeTunnels.has(tunnelName)) {
- verifyTunnelConnection(tunnelName, tunnelConfig, false);
+ broadcastTunnelStatus(tunnelName, {
+ connected: true,
+ status: CONNECTION_STATES.CONNECTED
+ });
+ setupPingInterval(tunnelName, tunnelConfig);
}
}, 2000);
@@ -901,7 +688,6 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
stream.stderr.on("data", (data) => {
const errorMsg = data.toString().trim();
- logger.debug(`Tunnel stderr for '${tunnelName}': ${errorMsg}`);
});
});
});
@@ -910,11 +696,11 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
host: tunnelConfig.sourceIP,
port: tunnelConfig.sourceSSHPort,
username: tunnelConfig.sourceUsername,
- keepaliveInterval: 30000,
+ keepaliveInterval: 30000,
keepaliveCountMax: 3,
- readyTimeout: 10000,
+ readyTimeout: 60000,
tcpKeepAlive: true,
- tcpKeepAliveInitialDelay: 30000,
+ tcpKeepAliveInitialDelay: 15000,
algorithms: {
kex: [
'diffie-hellman-group14-sha256',
@@ -952,8 +738,8 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
};
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
- if (!tunnelConfig.sourceSSHKey.includes('-----BEGIN')) {
- logger.error(`Invalid SSH key format for tunnel '${tunnelName}'. Key should start with '-----BEGIN'`);
+ if (!tunnelConfig.sourceSSHKey.includes('-----BEGIN') || !tunnelConfig.sourceSSHKey.includes('-----END')) {
+ logger.error(`Invalid SSH key format for tunnel '${tunnelName}'. Key should contain both BEGIN and END markers`);
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.FAILED,
@@ -962,7 +748,8 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
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) {
connOptions.passphrase = tunnelConfig.sourceKeyPassword;
}
@@ -981,43 +768,16 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
connOptions.password = tunnelConfig.sourcePassword;
}
- const testSocket = new net.Socket();
- testSocket.setTimeout(5000);
-
- testSocket.on('connect', () => {
- testSocket.destroy();
-
- const currentStatus = connectionStatus.get(tunnelName);
- if (!currentStatus || currentStatus.status !== CONNECTION_STATES.WAITING) {
- broadcastTunnelStatus(tunnelName, {
- connected: false,
- status: CONNECTION_STATES.CONNECTING,
- retryCount: retryAttempt > 0 ? retryAttempt : undefined
- });
- }
-
- conn.connect(connOptions);
- });
-
- testSocket.on('timeout', () => {
- testSocket.destroy();
+ const finalStatus = connectionStatus.get(tunnelName);
+ if (!finalStatus || finalStatus.status !== CONNECTION_STATES.WAITING) {
broadcastTunnelStatus(tunnelName, {
connected: false,
- status: CONNECTION_STATES.FAILED,
- reason: "Network connectivity test failed - server not reachable"
+ status: CONNECTION_STATES.CONNECTING,
+ retryCount: retryAttempt > 0 ? retryAttempt : undefined
});
- });
+ }
- 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);
+ conn.connect(connOptions);
}
function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string, callback: (err?: Error) => void) {
@@ -1029,9 +789,9 @@ function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string
username: tunnelConfig.sourceUsername,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
- readyTimeout: 10000,
+ readyTimeout: 60000,
tcpKeepAlive: true,
- tcpKeepAliveInitialDelay: 30000,
+ tcpKeepAliveInitialDelay: 15000,
algorithms: {
kex: [
'diffie-hellman-group14-sha256',
@@ -1068,7 +828,13 @@ function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string
}
};
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) {
connOptions.passphrase = tunnelConfig.sourceKeyPassword;
}
diff --git a/src/ui/Admin/AdminSettings.tsx b/src/ui/Admin/AdminSettings.tsx
index 1f18501c..bece9c0a 100644
--- a/src/ui/Admin/AdminSettings.tsx
+++ b/src/ui/Admin/AdminSettings.tsx
@@ -16,10 +16,16 @@ import {
TableRow,
} from "@/components/ui/table.tsx";
import {Shield, Trash2, Users} from "lucide-react";
-import axios from "axios";
-
-const apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users";
-const API = axios.create({baseURL: apiBase});
+import {
+ getOIDCConfig,
+ getRegistrationAllowed,
+ getUserList,
+ updateRegistrationAllowed,
+ updateOIDCConfig,
+ makeUserAdmin,
+ removeAdminStatus,
+ deleteUser
+} from "@/ui/main-axios.ts";
function getCookie(name: string) {
return document.cookie.split('; ').reduce((r, v) => {
@@ -67,9 +73,9 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
React.useEffect(() => {
const jwt = getCookie("jwt");
if (!jwt) return;
- API.get("/oidc-config", {headers: {Authorization: `Bearer ${jwt}`}})
+ getOIDCConfig()
.then(res => {
- if (res.data) setOidcConfig(res.data);
+ if (res) setOidcConfig(res);
})
.catch(() => {
});
@@ -77,10 +83,10 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
}, []);
React.useEffect(() => {
- API.get("/registration-allowed")
+ getRegistrationAllowed()
.then(res => {
- if (typeof res?.data?.allowed === 'boolean') {
- setAllowRegistration(res.data.allowed);
+ if (typeof res?.allowed === 'boolean') {
+ setAllowRegistration(res.allowed);
}
})
.catch(() => {
@@ -92,8 +98,8 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
if (!jwt) return;
setUsersLoading(true);
try {
- const response = await API.get("/list", {headers: {Authorization: `Bearer ${jwt}`}});
- setUsers(response.data.users);
+ const response = await getUserList();
+ setUsers(response.users);
} finally {
setUsersLoading(false);
}
@@ -103,7 +109,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
setRegLoading(true);
const jwt = getCookie("jwt");
try {
- await API.patch("/registration-allowed", {allowed: checked}, {headers: {Authorization: `Bearer ${jwt}`}});
+ await updateRegistrationAllowed(checked);
setAllowRegistration(checked);
} finally {
setRegLoading(false);
@@ -126,7 +132,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
const jwt = getCookie("jwt");
try {
- await API.post("/oidc-config", oidcConfig, {headers: {Authorization: `Bearer ${jwt}`}});
+ await updateOIDCConfig(oidcConfig);
setOidcSuccess("OIDC configuration updated successfully!");
} catch (err: any) {
setOidcError(err?.response?.data?.error || "Failed to update OIDC configuration");
@@ -147,7 +153,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
setMakeAdminSuccess(null);
const jwt = getCookie("jwt");
try {
- await API.post("/make-admin", {username: newAdminUsername.trim()}, {headers: {Authorization: `Bearer ${jwt}`}});
+ await makeUserAdmin(newAdminUsername.trim());
setMakeAdminSuccess(`User ${newAdminUsername} is now an admin`);
setNewAdminUsername("");
fetchUsers();
@@ -162,7 +168,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
if (!confirm(`Remove admin status from ${username}?`)) return;
const jwt = getCookie("jwt");
try {
- await API.post("/remove-admin", {username}, {headers: {Authorization: `Bearer ${jwt}`}});
+ await removeAdminStatus(username);
fetchUsers();
} catch {
}
@@ -172,7 +178,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
if (!confirm(`Delete user ${username}? This cannot be undone.`)) return;
const jwt = getCookie("jwt");
try {
- await API.delete("/delete-user", {headers: {Authorization: `Bearer ${jwt}`}, data: {username}});
+ await deleteUser(username);
fetchUsers();
} catch {
}
diff --git a/src/ui/Homepage/Homepage.tsx b/src/ui/Homepage/Homepage.tsx
index 38740e9d..b6fbe9ae 100644
--- a/src/ui/Homepage/Homepage.tsx
+++ b/src/ui/Homepage/Homepage.tsx
@@ -1,9 +1,9 @@
import React, {useEffect, useState} from "react";
import {HomepageAuth} from "@/ui/Homepage/HomepageAuth.tsx";
-import axios from "axios";
import {HomepageUpdateLog} from "@/ui/Homepage/HompageUpdateLog.tsx";
import {HomepageAlertManager} from "@/ui/Homepage/HomepageAlertManager.tsx";
import {Button} from "@/components/ui/button.tsx";
+import { getUserInfo, getDatabaseHealth } from "@/ui/main-axios.ts";
interface HomepageProps {
onSelectView: (view: string) => void;
@@ -25,12 +25,6 @@ function setCookie(name: string, value: string, days = 7) {
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({
onSelectView,
isAuthenticated,
@@ -53,13 +47,13 @@ export function Homepage({
const jwt = getCookie("jwt");
if (jwt) {
Promise.all([
- API.get("/me", {headers: {Authorization: `Bearer ${jwt}`}}),
- API.get("/db-health")
+ getUserInfo(),
+ getDatabaseHealth()
])
.then(([meRes]) => {
- setIsAdmin(!!meRes.data.is_admin);
- setUsername(meRes.data.username || null);
- setUserId(meRes.data.userId || null);
+ setIsAdmin(!!meRes.is_admin);
+ setUsername(meRes.username || null);
+ setUserId(meRes.userId || null);
setDbError(null);
})
.catch((err) => {
@@ -78,76 +72,82 @@ export function Homepage({
return (
-
-
-
-
- {loggedIn && (
-
+ {!loggedIn ? (
+
+
+
+ ) : (
+
+
+
-
Logged in!
-
+ className="text-center bg-[#18181b] border-2 border-[#303032] rounded-lg p-6 w-full shadow-lg">
+
Logged in!
+
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
host using the other apps in the sidebar.
-
+
window.open('https://github.com/LukeGus/Termix', '_blank')}
>
GitHub
-
+
window.open('https://github.com/LukeGus/Termix/issues/new', '_blank')}
>
Feedback
-
+
window.open('https://discord.com/invite/jVQGdvHDrf', '_blank')}
>
Discord
-
+
window.open('https://github.com/sponsors/LukeGus', '_blank')}
>
Donate
- )}
-
+
+
-
+ )}
([]);
const [currentAlertIndex, setCurrentAlertIndex] = useState(0);
@@ -44,9 +38,9 @@ export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): Rea
setError(null);
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 priorityOrder = {critical: 4, high: 3, medium: 2, low: 1};
@@ -73,10 +67,7 @@ export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): Rea
if (!userId) return;
try {
- const response = await API.post('/dismiss', {
- userId,
- alertId
- });
+ await dismissAlert(userId, alertId);
setAlerts(prev => {
const newAlerts = prev.filter(alert => alert.id !== alertId);
diff --git a/src/ui/Homepage/HomepageAuth.tsx b/src/ui/Homepage/HomepageAuth.tsx
index 43241e55..8504e434 100644
--- a/src/ui/Homepage/HomepageAuth.tsx
+++ b/src/ui/Homepage/HomepageAuth.tsx
@@ -1,10 +1,21 @@
import React, {useState, useEffect} from "react";
-import {cn} from "@/lib/utils.ts";
-import {Button} from "@/components/ui/button.tsx";
-import {Input} from "@/components/ui/input.tsx";
-import {Label} from "@/components/ui/label.tsx";
-import {Alert, AlertTitle, AlertDescription} from "@/components/ui/alert.tsx";
-import axios from "axios";
+import {cn} from "../../lib/utils.ts";
+import {Button} from "../../components/ui/button.tsx";
+import {Input} from "../../components/ui/input.tsx";
+import {Label} from "../../components/ui/label.tsx";
+import {Alert, AlertTitle, AlertDescription} from "../../components/ui/alert.tsx";
+import {
+ registerUser,
+ loginUser,
+ getUserInfo,
+ getRegistrationAllowed,
+ getOIDCConfig,
+ getUserCount,
+ initiatePasswordReset,
+ verifyPasswordResetCode,
+ completePasswordReset,
+ getOIDCAuthorizeUrl
+} from "../main-axios.ts";
function setCookie(name: string, value: string, days = 7) {
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"> {
setLoggedIn: (loggedIn: boolean) => void;
@@ -74,14 +81,14 @@ export function HomepageAuth({
}, [loggedIn]);
useEffect(() => {
- API.get("/registration-allowed").then(res => {
- setRegistrationAllowed(res.data.allowed);
+ getRegistrationAllowed().then(res => {
+ setRegistrationAllowed(res.allowed);
});
}, []);
useEffect(() => {
- API.get("/oidc-config").then((response) => {
- if (response.data) {
+ getOIDCConfig().then((response) => {
+ if (response) {
setOidcConfigured(true);
} else {
setOidcConfigured(false);
@@ -96,8 +103,8 @@ export function HomepageAuth({
}, []);
useEffect(() => {
- API.get("/count").then(res => {
- if (res.data.count === 0) {
+ getUserCount().then(res => {
+ if (res.count === 0) {
setFirstUser(true);
setTab("signup");
} else {
@@ -123,7 +130,7 @@ export function HomepageAuth({
try {
let res, meRes;
if (tab === "login") {
- res = await API.post("/login", {username: localUsername, password});
+ res = await loginUser(localUsername, password);
} else {
if (password !== signupConfirmPassword) {
setError("Passwords do not match");
@@ -135,31 +142,37 @@ export function HomepageAuth({
setLoading(false);
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([
- API.get("/me", {headers: {Authorization: `Bearer ${res.data.token}`}}),
- API.get("/db-health")
+ getUserInfo(),
]);
+
setInternalLoggedIn(true);
setLoggedIn(true);
- setIsAdmin(!!meRes.data.is_admin);
- setUsername(meRes.data.username || null);
- setUserId(meRes.data.id || null);
+ setIsAdmin(!!meRes.is_admin);
+ setUsername(meRes.username || null);
+ setUserId(meRes.userId || null);
setDbError(null);
onAuthSuccess({
- isAdmin: !!meRes.data.is_admin,
- username: meRes.data.username || null,
- userId: meRes.data.id || null
+ isAdmin: !!meRes.is_admin,
+ username: meRes.username || null,
+ userId: meRes.userId || null
});
setInternalLoggedIn(true);
if (tab === "signup") {
setSignupConfirmPassword("");
}
} catch (err: any) {
- setError(err?.response?.data?.error || "Unknown error");
+ setError(err?.response?.data?.error || err?.message || "Unknown error");
setInternalLoggedIn(false);
setLoggedIn(false);
setIsAdmin(false);
@@ -176,29 +189,26 @@ export function HomepageAuth({
}
}
- async function initiatePasswordReset() {
+ async function handleInitiatePasswordReset() {
setError(null);
setResetLoading(true);
try {
- await API.post("/initiate-reset", {username: localUsername});
+ const result = await initiatePasswordReset(localUsername);
setResetStep("verify");
setError(null);
} 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 {
setResetLoading(false);
}
}
- async function verifyResetCode() {
+ async function handleVerifyResetCode() {
setError(null);
setResetLoading(true);
try {
- const response = await API.post("/verify-reset-code", {
- username: localUsername,
- resetCode: resetCode
- });
- setTempToken(response.data.tempToken);
+ const response = await verifyPasswordResetCode(localUsername, resetCode);
+ setTempToken(response.tempToken);
setResetStep("newPassword");
setError(null);
} catch (err: any) {
@@ -208,7 +218,7 @@ export function HomepageAuth({
}
}
- async function completePasswordReset() {
+ async function handleCompletePasswordReset() {
setError(null);
setResetLoading(true);
@@ -225,11 +235,7 @@ export function HomepageAuth({
}
try {
- await API.post("/complete-reset", {
- username: localUsername,
- tempToken: tempToken,
- newPassword: newPassword
- });
+ await completePasswordReset(localUsername, tempToken, newPassword);
setResetStep("initiate");
setResetCode("");
@@ -267,8 +273,8 @@ export function HomepageAuth({
setError(null);
setOidcLoading(true);
try {
- const authResponse = await API.get("/oidc/authorize");
- const {auth_url: authUrl} = authResponse.data;
+ const authResponse = await getOIDCAuthorizeUrl();
+ const {auth_url: authUrl} = authResponse;
if (!authUrl || authUrl === 'undefined') {
throw new Error('Invalid authorization URL received from backend');
@@ -299,18 +305,18 @@ export function HomepageAuth({
setError(null);
setCookie("jwt", token);
- API.get("/me", {headers: {Authorization: `Bearer ${token}`}})
+ getUserInfo()
.then(meRes => {
setInternalLoggedIn(true);
setLoggedIn(true);
- setIsAdmin(!!meRes.data.is_admin);
- setUsername(meRes.data.username || null);
- setUserId(meRes.data.id || null);
+ setIsAdmin(!!meRes.is_admin);
+ setUsername(meRes.username || null);
+ setUserId(meRes.id || null);
setDbError(null);
onAuthSuccess({
- isAdmin: !!meRes.data.is_admin,
- username: meRes.data.username || null,
- userId: meRes.data.id || null
+ isAdmin: !!meRes.is_admin,
+ username: meRes.username || null,
+ userId: meRes.id || null
});
setInternalLoggedIn(true);
window.history.replaceState({}, document.title, window.location.pathname);
@@ -340,7 +346,7 @@ export function HomepageAuth({
return (
{dbError && (
@@ -486,7 +492,7 @@ export function HomepageAuth({
type="button"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading || !localUsername.trim()}
- onClick={initiatePasswordReset}
+ onClick={handleInitiatePasswordReset}
>
{resetLoading ? Spinner : "Send Reset Code"}
@@ -519,7 +525,7 @@ export function HomepageAuth({
type="button"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading || resetCode.length !== 6}
- onClick={verifyResetCode}
+ onClick={handleVerifyResetCode}
>
{resetLoading ? Spinner : "Verify Code"}
@@ -598,7 +604,7 @@ export function HomepageAuth({
type="button"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading || !newPassword || !confirmPassword}
- onClick={completePasswordReset}
+ onClick={handleCompletePasswordReset}
>
{resetLoading ? Spinner : "Reset Password"}
@@ -680,4 +686,4 @@ export function HomepageAuth({
)}
);
-}
+}
\ No newline at end of file
diff --git a/src/ui/Homepage/HompageUpdateLog.tsx b/src/ui/Homepage/HompageUpdateLog.tsx
index 203327e2..ad85aafd 100644
--- a/src/ui/Homepage/HompageUpdateLog.tsx
+++ b/src/ui/Homepage/HompageUpdateLog.tsx
@@ -2,7 +2,7 @@ import React, {useEffect, useState} from "react";
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
import {Button} from "@/components/ui/button.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"> {
loggedIn: boolean;
@@ -50,12 +50,6 @@ interface VersionResponse {
cache_age?: number;
}
-const apiBase = import.meta.env.DEV ? "http://localhost:8081" : "";
-
-const API = axios.create({
- baseURL: apiBase,
-});
-
export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
const [releases, setReleases] = useState(null);
const [versionInfo, setVersionInfo] = useState(null);
@@ -66,12 +60,12 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
if (loggedIn) {
setLoading(true);
Promise.all([
- API.get('/releases/rss?per_page=100'),
- API.get('/version/')
+ getReleasesRSS(100),
+ getVersionInfo()
])
.then(([releasesRes, versionRes]) => {
- setReleases(releasesRes.data);
- setVersionInfo(versionRes.data);
+ setReleases(releasesRes);
+ setVersionInfo(versionRes);
setError(null);
})
.catch(err => {
@@ -95,70 +89,63 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
};
return (
-
+
-
Updates & Releases
+
Updates & Releases
-
+
{versionInfo && versionInfo.status === 'requires_update' && (
-
- Update Available
-
+
+ Update Available
+
A new version ({versionInfo.version}) is available.
- window.open("https://docs.termix.site/docs", '_blank')}
- >
- Update now
-
)}
{versionInfo && versionInfo.status === 'requires_update' && (
-
+
)}
-
+
{loading && (
)}
{error && (
-
- Error
- {error}
+
+ Error
+ {error}
)}
{releases?.items.map((release) => (
window.open(release.link, '_blank')}
>
-
+
{release.title}
{release.isPrerelease && (
+ className="text-xs bg-yellow-600 text-yellow-100 px-2 py-1 rounded ml-2 flex-shrink-0 font-medium">
Pre-release
)}
-
+
{formatDescription(release.description)}
-
+
{new Date(release.pubDate).toLocaleDateString()}
{release.assets.length > 0 && (
<>
@@ -171,9 +158,9 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
))}
{releases && releases.items.length === 0 && !loading && (
-
- No Releases
-
+
+ No Releases
+
No releases found.
diff --git a/src/ui/Navigation/AppView.tsx b/src/ui/Navigation/AppView.tsx
index 006bc269..51117c69 100644
--- a/src/ui/Navigation/AppView.tsx
+++ b/src/ui/Navigation/AppView.tsx
@@ -1,5 +1,5 @@
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 {FileManager} from "@/ui/apps/File Manager/FileManager.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[];
if (allSplitScreenTab.length === 0 && mainTab) {
+ const isFileManagerTab = mainTab.type === 'file_manager';
styles[mainTab.id] = {
position: 'absolute',
- top: 2,
- left: 2,
- right: 2,
- bottom: 2,
+ top: isFileManagerTab ? 0 : 2,
+ left: isFileManagerTab ? 0 : 2,
+ right: isFileManagerTab ? 0 : 2,
+ bottom: isFileManagerTab ? 0 : 2,
zIndex: 20,
display: 'block',
pointerEvents: 'auto',
@@ -154,9 +155,9 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
const effectiveVisible = isVisible && ready;
return (
-
+
{t.type === 'terminal' ? (
-
tab.id === currentTab);
+ const isFileManager = currentTabData?.type === 'file_manager';
+ const isSplitScreen = allSplitScreenTab.length > 0;
+
const topMarginPx = isTopbarOpen ? 74 : 26;
const leftMarginPx = sidebarState === 'collapsed' ? 26 : 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"
style={{
position: 'relative',
- background: '#18181b',
+ background: (isFileManager && !isSplitScreen) ? '#09090b' : '#18181b',
marginLeft: leftMarginPx,
marginRight: 17,
marginTop: topMarginPx,
diff --git a/src/ui/Navigation/LeftSidebar.tsx b/src/ui/Navigation/LeftSidebar.tsx
index 53c50b01..f60383a9 100644
--- a/src/ui/Navigation/LeftSidebar.tsx
+++ b/src/ui/Navigation/LeftSidebar.tsx
@@ -45,11 +45,18 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table.tsx";
-import axios from "axios";
import {Card} from "@/components/ui/card.tsx";
import {FolderCard} from "@/ui/Navigation/Hosts/FolderCard.tsx";
import {getSSHHosts} from "@/ui/main-axios.ts";
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
+import {
+ getOIDCConfig,
+ getUserList,
+ makeUserAdmin,
+ removeAdminStatus,
+ deleteUser,
+ deleteAccount
+} from "@/ui/main-axios.ts";
interface SSHHost {
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({
onSelectView,
@@ -162,9 +165,9 @@ export function LeftSidebar({
if (adminSheetOpen) {
const jwt = getCookie("jwt");
if (jwt && isAdmin) {
- API.get("/oidc-config").then(res => {
- if (res.data) {
- setOidcConfig(res.data);
+ getOIDCConfig().then(res => {
+ if (res) {
+ setOidcConfig(res);
}
}).catch((error) => {
});
@@ -235,7 +238,7 @@ export function LeftSidebar({
React.useEffect(() => {
fetchHosts();
- const interval = setInterval(fetchHosts, 10000);
+ const interval = setInterval(fetchHosts, 300000); // 5 minutes instead of 10 seconds
return () => clearInterval(interval);
}, [fetchHosts]);
@@ -308,10 +311,7 @@ export function LeftSidebar({
const jwt = getCookie("jwt");
try {
- await API.delete("/delete-account", {
- headers: {Authorization: `Bearer ${jwt}`},
- data: {password: deletePassword}
- });
+ await deleteAccount(deletePassword);
handleLogout();
} catch (err: any) {
@@ -329,12 +329,10 @@ export function LeftSidebar({
setUsersLoading(true);
try {
- const response = await API.get("/list", {
- headers: {Authorization: `Bearer ${jwt}`}
- });
- setUsers(response.data.users);
+ const response = await getUserList();
+ setUsers(response.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);
} catch (err: any) {
} finally {
@@ -350,10 +348,8 @@ export function LeftSidebar({
}
try {
- const response = await API.get("/list", {
- headers: {Authorization: `Bearer ${jwt}`}
- });
- const adminUsers = response.data.users.filter((user: any) => user.is_admin);
+ const response = await getUserList();
+ const adminUsers = response.users.filter((user: any) => user.is_admin);
setAdminCount(adminUsers.length);
} catch (err: any) {
}
@@ -373,10 +369,7 @@ export function LeftSidebar({
const jwt = getCookie("jwt");
try {
- await API.post("/make-admin",
- {username: newAdminUsername.trim()},
- {headers: {Authorization: `Bearer ${jwt}`}}
- );
+ await makeUserAdmin(newAdminUsername.trim());
setMakeAdminSuccess(`User ${newAdminUsername} is now an admin`);
setNewAdminUsername("");
fetchUsers();
@@ -396,10 +389,7 @@ export function LeftSidebar({
const jwt = getCookie("jwt");
try {
- await API.post("/remove-admin",
- {username},
- {headers: {Authorization: `Bearer ${jwt}`}}
- );
+ await removeAdminStatus(username);
fetchUsers();
} catch (err: any) {
}
@@ -414,10 +404,7 @@ export function LeftSidebar({
const jwt = getCookie("jwt");
try {
- await API.delete("/delete-user", {
- headers: {Authorization: `Bearer ${jwt}`},
- data: {username}
- });
+ await deleteUser(username);
fetchUsers();
} catch (err: any) {
}
diff --git a/src/ui/Navigation/Tabs/TabContext.tsx b/src/ui/Navigation/Tabs/TabContext.tsx
index 22a568ba..5d8104bc 100644
--- a/src/ui/Navigation/Tabs/TabContext.tsx
+++ b/src/ui/Navigation/Tabs/TabContext.tsx
@@ -50,7 +50,7 @@ export function TabProvider({children}: TabProviderProps) {
const usedNumbers = new Set();
let rootUsed = false;
tabs.forEach(t => {
- if (t.type !== tabType || !t.title) return;
+ if (!t.title) return;
if (t.title === root) {
rootUsed = true;
return;
diff --git a/src/ui/apps/File Manager/FileManager.tsx b/src/ui/apps/File Manager/FileManager.tsx
index 94ce24a8..ddb397b9 100644
--- a/src/ui/apps/File Manager/FileManager.tsx
+++ b/src/ui/apps/File Manager/FileManager.tsx
@@ -8,6 +8,7 @@ import {Button} from '@/components/ui/button.tsx';
import {FIleManagerTopNavbar} from "@/ui/apps/File Manager/FIleManagerTopNavbar.tsx";
import {cn} from '@/lib/utils.ts';
import {Save, RefreshCw, Settings, Trash2} from 'lucide-react';
+import {Separator} from '@/components/ui/separator.tsx';
import {toast} from 'sonner';
import {
getFileManagerRecent,
@@ -489,7 +490,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
if (!currentHost) {
return (
-
+
{
@@ -525,7 +526,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
}
return (
-
+
{
@@ -570,6 +571,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
>
+
{
@@ -599,9 +601,9 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
display: 'flex',
flexDirection: 'column'
}}>
- {activeTab === 'home' ? (
-
-
+
+
+ {activeTab === 'home' ? (
-
- {showOperations && (
-
-
-
+ ) : (
+ (() => {
+ const tab = tabs.find(t => t.id === activeTab);
+ if (!tab) return null;
+ return (
+
+
+ setTabContent(tab.id, content)}
+ />
+
+
+ );
+ })()
)}
- ) : (
- (() => {
- const tab = tabs.find(t => t.id === activeTab);
- if (!tab) return null;
- return (
-
-
- setTabContent(tab.id, content)}
- />
-
-
- );
- })()
- )}
+ {showOperations && (
+
+
+
+ )}
+
{deletingItem && (
diff --git a/src/ui/apps/File Manager/FileManagerOperations.tsx b/src/ui/apps/File Manager/FileManagerOperations.tsx
index 15bc85ec..6a974d91 100644
--- a/src/ui/apps/File Manager/FileManagerOperations.tsx
+++ b/src/ui/apps/File Manager/FileManagerOperations.tsx
@@ -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 {Input} from '@/components/ui/input.tsx';
import {Card} from '@/components/ui/card.tsx';
@@ -48,7 +48,29 @@ export function FileManagerOperations({
const [newName, setNewName] = useState('');
const [isLoading, setIsLoading] = useState(false);
+ const [showTextLabels, setShowTextLabels] = useState(true);
const fileInputRef = useRef(null);
+ const containerRef = useRef(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 () => {
if (!uploadFile || !sshSessionId) return;
@@ -186,113 +208,121 @@ export function FileManagerOperations({
}
return (
-
+
setShowUpload(true)}
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
+ title="Upload File"
>
-
- Upload File
+
+ {showTextLabels && Upload File }
setShowCreateFile(true)}
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
+ title="New File"
>
-
- New File
+
+ {showTextLabels && New File }
setShowCreateFolder(true)}
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
+ title="New Folder"
>
-
- New Folder
+
+ {showTextLabels && New Folder }
setShowRename(true)}
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
+ title="Rename"
>
-
- Rename
+
+ {showTextLabels && Rename }
setShowDelete(true)}
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30] col-span-2"
+ title="Delete Item"
>
-
- Delete Item
+
+ {showTextLabels && Delete Item }
-
-
-
-
Current Path:
-
{currentPath}
+
+
+
+
+ Current Path:
+ {currentPath}
+
{showUpload && (
-
-
-
-
-
- Upload File
+
+
+
+
+
+ Upload File
-
- Maximum file size: 100MB (JSON) / 200MB (Binary)
+
+ Max: 100MB (JSON) / 200MB (Binary)
setShowUpload(false)}
- className="h-8 w-8 p-0"
+ className="h-8 w-8 p-0 flex-shrink-0 ml-2"
>
-
-
+
+
{uploadFile ? (
-
-
-
{uploadFile.name}
-
+
+
+
{uploadFile.name}
+
{(uploadFile.size / 1024).toFixed(2)} KB
setUploadFile(null)}
- className="mt-2"
+ className="w-full text-sm h-8"
>
Remove File
) : (
-
-
-
Click to select a file
+
+
+
Click to select a file
Choose File
@@ -308,11 +338,11 @@ export function FileManagerOperations({
accept="*/*"
/>
-
+
{isLoading ? 'Uploading...' : 'Upload File'}
@@ -320,6 +350,7 @@ export function FileManagerOperations({
variant="outline"
onClick={() => setShowUpload(false)}
disabled={isLoading}
+ className="w-full text-sm h-9"
>
Cancel
@@ -329,23 +360,25 @@ export function FileManagerOperations({
)}
{showCreateFile && (
-
-
-
-
- Create New File
-
+
+
+
+
+
+ Create New File
+
+
setShowCreateFile(false)}
- className="h-8 w-8 p-0"
+ className="h-8 w-8 p-0 flex-shrink-0 ml-2"
>
-
+
File Name
@@ -354,16 +387,16 @@ export function FileManagerOperations({
value={newFileName}
onChange={(e) => setNewFileName(e.target.value)}
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()}
/>
-
+
{isLoading ? 'Creating...' : 'Create File'}
@@ -371,6 +404,7 @@ export function FileManagerOperations({
variant="outline"
onClick={() => setShowCreateFile(false)}
disabled={isLoading}
+ className="w-full text-sm h-9"
>
Cancel
@@ -380,23 +414,25 @@ export function FileManagerOperations({
)}
{showCreateFolder && (
-
-
-
-
- Create New Folder
-
+
+
+
+
+
+ Create New Folder
+
+
setShowCreateFolder(false)}
- className="h-8 w-8 p-0"
+ className="h-8 w-8 p-0 flex-shrink-0 ml-2"
>
-
+
Folder Name
@@ -405,16 +441,16 @@ export function FileManagerOperations({
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
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()}
/>
-
+
{isLoading ? 'Creating...' : 'Create Folder'}
@@ -422,6 +458,7 @@ export function FileManagerOperations({
variant="outline"
onClick={() => setShowCreateFolder(false)}
disabled={isLoading}
+ className="w-full text-sm h-9"
>
Cancel
@@ -431,27 +468,29 @@ export function FileManagerOperations({
)}
{showDelete && (
-
-
-
-
- Delete Item
-
+
+
+
+
+
+ Delete Item
+
+
setShowDelete(false)}
- className="h-8 w-8 p-0"
+ className="h-8 w-8 p-0 flex-shrink-0 ml-2"
>
-
+
-
-
-
Warning: This action cannot be undone
+
+
+
Warning: This action cannot be undone
@@ -462,30 +501,30 @@ export function FileManagerOperations({
setDeletePath(e.target.value)}
- placeholder="Enter full path to item (e.g., /path/to/file.txt)"
- className="bg-[#23232a] border-2 border-[#434345] text-white"
+ placeholder="Enter full path to item"
+ className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
/>
-
+
setDeleteIsDirectory(e.target.checked)}
- className="rounded border-[#434345] bg-[#23232a]"
+ className="rounded border-[#434345] bg-[#23232a] mt-0.5 flex-shrink-0"
/>
-
+
This is a directory (will delete recursively)
-
+
{isLoading ? 'Deleting...' : 'Delete Item'}
@@ -493,6 +532,7 @@ export function FileManagerOperations({
variant="outline"
onClick={() => setShowDelete(false)}
disabled={isLoading}
+ className="w-full text-sm h-9"
>
Cancel
@@ -502,23 +542,25 @@ export function FileManagerOperations({
)}
{showRename && (
-
-
-
-
- Rename Item
-
+
+
+
+
+
+ Rename Item
+
+
setShowRename(false)}
- className="h-8 w-8 p-0"
+ className="h-8 w-8 p-0 flex-shrink-0 ml-2"
>
-
+
Current Path
@@ -527,7 +569,7 @@ export function FileManagerOperations({
value={renamePath}
onChange={(e) => setRenamePath(e.target.value)}
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"
/>
@@ -539,29 +581,29 @@ export function FileManagerOperations({
value={newName}
onChange={(e) => setNewName(e.target.value)}
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()}
/>
-
+
setRenameIsDirectory(e.target.checked)}
- className="rounded border-[#434345] bg-[#23232a]"
+ className="rounded border-[#434345] bg-[#23232a] mt-0.5 flex-shrink-0"
/>
-
+
This is a directory
-
+
{isLoading ? 'Renaming...' : 'Rename Item'}
@@ -569,6 +611,7 @@ export function FileManagerOperations({
variant="outline"
onClick={() => setShowRename(false)}
disabled={isLoading}
+ className="w-full text-sm h-9"
>
Cancel
diff --git a/src/ui/apps/File Manager/FileManagerTabList.tsx b/src/ui/apps/File Manager/FileManagerTabList.tsx
index b43c1cdc..e46a7e22 100644
--- a/src/ui/apps/File Manager/FileManagerTabList.tsx
+++ b/src/ui/apps/File Manager/FileManagerTabList.tsx
@@ -21,7 +21,7 @@ export function FileManagerTabList({tabs, activeTab, setActiveTab, closeTab, onH
diff --git a/src/ui/apps/Host Manager/HostManagerHostEditor.tsx b/src/ui/apps/Host Manager/HostManagerHostEditor.tsx
index e7c5467c..7dc84072 100644
--- a/src/ui/apps/Host Manager/HostManagerHostEditor.tsx
+++ b/src/ui/apps/Host Manager/HostManagerHostEditor.tsx
@@ -582,11 +582,11 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
(
+ render={({ field }) => (
Password
-
+
)}
diff --git a/src/ui/apps/Host Manager/HostManagerHostViewer.tsx b/src/ui/apps/Host Manager/HostManagerHostViewer.tsx
index 93ee0341..aa4e65ca 100644
--- a/src/ui/apps/Host Manager/HostManagerHostViewer.tsx
+++ b/src/ui/apps/Host Manager/HostManagerHostViewer.tsx
@@ -677,7 +677,7 @@ EXAMPLE STRUCTURE:
{host.tags && host.tags.length > 0 && (
{host.tags.slice(0, 6).map((tag, index) => (
-
{tag}
diff --git a/src/ui/apps/Server/Server.tsx b/src/ui/apps/Server/Server.tsx
index be6a4470..1b5c2f65 100644
--- a/src/ui/apps/Server/Server.tsx
+++ b/src/ui/apps/Server/Server.tsx
@@ -25,7 +25,7 @@ export function Server({
embedded = false
}: ServerProps): React.ReactElement {
const {state: sidebarState} = useSidebar();
- const {addTab} = useTabs() as any;
+ const {addTab, tabs} = useTabs() as any;
const [serverStatus, setServerStatus] = React.useState<'online' | 'offline'>('offline');
const [metrics, setMetrics] = React.useState(null);
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
@@ -94,25 +94,37 @@ export function Server({
}
};
- if (currentHostConfig?.id) {
+ if (currentHostConfig?.id && isVisible) {
fetchStatus();
fetchMetrics();
+ // Only poll when component is visible to reduce unnecessary connections
intervalId = window.setInterval(() => {
- fetchStatus();
- fetchMetrics();
- }, 10_000);
+ if (isVisible) {
+ fetchStatus();
+ fetchMetrics();
+ }
+ }, 300_000); // 5 minutes instead of 10 seconds
}
return () => {
cancelled = true;
if (intervalId) window.clearInterval(intervalId);
};
- }, [currentHostConfig?.id]);
+ }, [currentHostConfig?.id, isVisible]);
const topMarginPx = isTopbarOpen ? 74 : 16;
const leftMarginPx = sidebarState === 'collapsed' ? 16 : 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
? {opacity: isVisible ? 1 : 0, height: '100%', width: '100%'}
: {
@@ -142,13 +154,34 @@ export function Server({
-
+
+
{
+ 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
+
{currentHostConfig?.enableFileManager && (
{
- if (!currentHostConfig) return;
+ if (!currentHostConfig || isFileManagerAlreadyOpen) return;
const titleBase = currentHostConfig?.name && currentHostConfig.name.trim() !== ''
? currentHostConfig.name.trim()
: `${currentHostConfig.username}@${currentHostConfig.ip}`;
@@ -210,7 +243,7 @@ export function Server({
- {/* HDD */}
+ {/* Root Storage */}
@@ -221,7 +254,7 @@ export function Server({
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
const usedText = used ?? 'N/A';
const totalText = total ?? 'N/A';
- return `HDD Space - ${pctText} (${usedText} of ${totalText})`;
+ return `Root Storage Space - ${pctText} (${usedText} of ${totalText})`;
})()}
diff --git a/src/ui/apps/Terminal/TerminalComponent.tsx b/src/ui/apps/Terminal/Terminal.tsx
similarity index 79%
rename from src/ui/apps/Terminal/TerminalComponent.tsx
rename to src/ui/apps/Terminal/Terminal.tsx
index d77ac606..9592bba0 100644
--- a/src/ui/apps/Terminal/TerminalComponent.tsx
+++ b/src/ui/apps/Terminal/Terminal.tsx
@@ -13,7 +13,7 @@ interface SSHTerminalProps {
splitScreen?: boolean;
}
-export const TerminalComponent = forwardRef
(function SSHTerminal(
+export const Terminal = forwardRef(function SSHTerminal(
{hostConfig, isVisible, splitScreen = false},
ref
) {
@@ -26,6 +26,7 @@ export const TerminalComponent = forwardRef(function SSHT
const [visible, setVisible] = useState(false);
const isVisibleRef = useRef(false);
+
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null);
const notifyTimerRef = useRef(null);
@@ -115,6 +116,50 @@ export const TerminalComponent = forwardRef(function SSHT
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 {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
@@ -222,48 +267,26 @@ export const TerminalComponent = forwardRef(function SSHT
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
hardRefresh();
setVisible(true);
+ if (terminal && !splitScreen) {
+ terminal.focus();
+ }
}, 0);
const cols = terminal.cols;
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);
webSocketRef.current = ws;
wasDisconnectedBySSH.current = false;
- 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]');
- });
+ setupWebSocketListeners(ws, cols, rows);
}, 300);
});
@@ -286,9 +309,18 @@ export const TerminalComponent = forwardRef(function SSHT
fitAddonRef.current?.fit();
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
hardRefresh();
+ if (terminal && !splitScreen) {
+ terminal.focus();
+ }
}, 0);
+
+ if (terminal && !splitScreen) {
+ setTimeout(() => {
+ terminal.focus();
+ }, 100);
+ }
}
- }, [isVisible]);
+ }, [isVisible, splitScreen, terminal]);
useEffect(() => {
if (!fitAddonRef.current) return;
@@ -296,12 +328,23 @@ export const TerminalComponent = forwardRef(function SSHT
fitAddonRef.current?.fit();
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
hardRefresh();
+ if (terminal && !splitScreen && isVisible) {
+ terminal.focus();
+ }
}, 0);
- }, [splitScreen]);
+ }, [splitScreen, isVisible, terminal]);
return (
-
+ {
+ if (terminal && !splitScreen) {
+ terminal.focus();
+ }
+ }}
+ />
);
});
diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts
index c1192d85..fc3e3744 100644
--- a/src/ui/main-axios.ts
+++ b/src/ui/main-axios.ts
@@ -1,4 +1,8 @@
-import axios from 'axios';
+import axios, { AxiosError, type AxiosInstance } from 'axios';
+
+// ============================================================================
+// TYPES & INTERFACES
+// ============================================================================
interface SSHHostData {
name?: string;
@@ -93,47 +97,70 @@ interface FileManagerShortcut {
path: string;
}
+interface FileManagerOperation {
+ name: string;
+ path: string;
+ isSSH: boolean;
+ sshSessionId?: string;
+ hostId: number;
+}
+
export type ServerStatus = {
status: 'online' | 'offline';
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 = {
- cpu: { percent: number | null; cores: number | null; load: [number, number, number] | null };
- memory: { percent: number | null; usedGiB: number | null; totalGiB: number | null };
- disk: { percent: number | null; usedHuman: string | null; totalHuman: string | null };
+ cpu: CpuMetrics;
+ memory: MemoryMetrics;
+ disk: DiskMetrics;
lastChecked: string;
};
-const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
+interface AuthResponse {
+ token: string;
+}
-const sshHostApi = axios.create({
- baseURL: isLocalhost ? 'http://localhost:8081' : '',
- headers: {
- 'Content-Type': 'application/json',
- },
-});
+interface UserInfo {
+ id: string;
+ username: string;
+ is_admin: boolean;
+}
-const tunnelApi = axios.create({
- baseURL: isLocalhost ? 'http://localhost:8083' : '',
- headers: {
- 'Content-Type': 'application/json',
- },
-});
+interface UserCount {
+ count: number;
+}
-const fileManagerApi = axios.create({
- baseURL: isLocalhost ? 'http://localhost:8084' : '',
- headers: {
- 'Content-Type': 'application/json',
- }
-})
+interface OIDCAuthorize {
+ auth_url: string;
+}
-const statsApi = axios.create({
- baseURL: isLocalhost ? 'http://localhost:8085' : '',
- headers: {
- 'Content-Type': 'application/json',
- }
-})
+// ============================================================================
+// UTILITY FUNCTIONS
+// ============================================================================
+
+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 {
const value = `; ${document.cookie}`;
@@ -141,44 +168,116 @@ function getCookie(name: string): string | undefined {
if (parts.length === 2) return parts.pop()?.split(';').shift();
}
-sshHostApi.interceptors.request.use((config) => {
- const token = getCookie('jwt');
- if (token) {
- config.headers.Authorization = `Bearer ${token}`;
- }
- return config;
-});
+function createApiInstance(baseURL: string): AxiosInstance {
+ const instance = axios.create({
+ baseURL,
+ headers: { 'Content-Type': 'application/json' },
+ timeout: 30000,
+ });
-statsApi.interceptors.request.use((config) => {
- const token = getCookie('jwt');
- if (token) {
- config.headers.Authorization = `Bearer ${token}`;
- }
- return config;
-});
+ instance.interceptors.request.use((config) => {
+ const token = getCookie('jwt');
+ if (token) {
+ config.headers.Authorization = `Bearer ${token}`;
+ }
+ return config;
+ });
-tunnelApi.interceptors.request.use((config) => {
- const token = getCookie('jwt');
- if (token) {
- config.headers.Authorization = `Bearer ${token}`;
- }
- return config;
-});
+ instance.interceptors.response.use(
+ (response) => response,
+ (error: AxiosError) => {
+ if (error.response?.status === 401) {
+ document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
+ }
+ return Promise.reject(error);
+ }
+ );
-fileManagerApi.interceptors.request.use((config) => {
- const token = getCookie('jwt');
- if (token) {
- config.headers.Authorization = `Bearer ${token}`;
+ return instance;
+}
+
+// ============================================================================
+// 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
{
try {
- const response = await sshHostApi.get('/ssh/db/host');
+ const response = await sshHostApi.get('/db/host');
return response.data;
} catch (error) {
- throw error;
+ handleApiError(error, 'fetch SSH hosts');
}
}
@@ -216,23 +315,20 @@ export async function createSSHHost(hostData: SSHHostData): Promise {
const formData = new FormData();
formData.append('key', hostData.key);
- const dataWithoutFile = {...submitData};
+ const dataWithoutFile = { ...submitData };
delete dataWithoutFile.key;
formData.append('data', JSON.stringify(dataWithoutFile));
- const response = await sshHostApi.post('/ssh/db/host', formData, {
- headers: {
- 'Content-Type': 'multipart/form-data',
- },
+ const response = await sshHostApi.post('/db/host', formData, {
+ headers: { 'Content-Type': 'multipart/form-data' },
});
-
return response.data;
} else {
- const response = await sshHostApi.post('/ssh/db/host', submitData);
+ const response = await sshHostApi.post('/db/host', submitData);
return response.data;
}
} 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();
formData.append('key', hostData.key);
- const dataWithoutFile = {...submitData};
+ const dataWithoutFile = { ...submitData };
delete dataWithoutFile.key;
formData.append('data', JSON.stringify(dataWithoutFile));
- const response = await sshHostApi.put(`/ssh/db/host/${hostId}`, formData, {
- headers: {
- 'Content-Type': 'multipart/form-data',
- },
+ const response = await sshHostApi.put(`/db/host/${hostId}`, formData, {
+ headers: { 'Content-Type': 'multipart/form-data' },
});
-
return response.data;
} else {
- const response = await sshHostApi.put(`/ssh/db/host/${hostId}`, submitData);
+ const response = await sshHostApi.put(`/db/host/${hostId}`, submitData);
return response.data;
}
} catch (error) {
- throw error;
+ handleApiError(error, 'update SSH host');
}
}
@@ -296,37 +389,41 @@ export async function bulkImportSSHHosts(hosts: SSHHostData[]): Promise<{
errors: string[];
}> {
try {
- const response = await sshHostApi.post('/ssh/bulk-import', {hosts});
+ const response = await sshHostApi.post('/bulk-import', { hosts });
return response.data;
} catch (error) {
- throw error;
+ handleApiError(error, 'bulk import SSH hosts');
}
}
export async function deleteSSHHost(hostId: number): Promise {
try {
- const response = await sshHostApi.delete(`/ssh/db/host/${hostId}`);
+ const response = await sshHostApi.delete(`/db/host/${hostId}`);
return response.data;
} catch (error) {
- throw error;
+ handleApiError(error, 'delete SSH host');
}
}
export async function getSSHHostById(hostId: number): Promise {
try {
- const response = await sshHostApi.get(`/ssh/db/host/${hostId}`);
+ const response = await sshHostApi.get(`/db/host/${hostId}`);
return response.data;
} catch (error) {
- throw error;
+ handleApiError(error, 'fetch SSH host');
}
}
+// ============================================================================
+// TUNNEL MANAGEMENT
+// ============================================================================
+
export async function getTunnelStatuses(): Promise> {
try {
- const response = await tunnelApi.get('/ssh/tunnel/status');
+ const response = await tunnelApi.get('/tunnel/status');
return response.data || {};
} catch (error) {
- throw error;
+ handleApiError(error, 'fetch tunnel statuses');
}
}
@@ -337,148 +434,120 @@ export async function getTunnelStatusByName(tunnelName: string): Promise {
try {
- const response = await tunnelApi.post('/ssh/tunnel/connect', tunnelConfig);
+ const response = await tunnelApi.post('/tunnel/connect', tunnelConfig);
return response.data;
} catch (error) {
- throw error;
+ handleApiError(error, 'connect tunnel');
}
}
export async function disconnectTunnel(tunnelName: string): Promise {
try {
- const response = await tunnelApi.post('/ssh/tunnel/disconnect', {tunnelName});
+ const response = await tunnelApi.post('/tunnel/disconnect', { tunnelName });
return response.data;
} catch (error) {
- throw error;
+ handleApiError(error, 'disconnect tunnel');
}
}
export async function cancelTunnel(tunnelName: string): Promise {
try {
- const response = await tunnelApi.post('/ssh/tunnel/cancel', {tunnelName});
+ const response = await tunnelApi.post('/tunnel/cancel', { tunnelName });
return response.data;
} catch (error) {
- throw error;
+ handleApiError(error, 'cancel tunnel');
}
}
+// ============================================================================
+// FILE MANAGER METADATA (Recent, Pinned, Shortcuts)
+// ============================================================================
+
export async function getFileManagerRecent(hostId: number): Promise {
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 || [];
} catch (error) {
return [];
}
}
-export async function addFileManagerRecent(file: {
- name: string;
- path: string;
- isSSH: boolean;
- sshSessionId?: string;
- hostId: number
-}): Promise {
+export async function addFileManagerRecent(file: FileManagerOperation): Promise {
try {
- const response = await sshHostApi.post('/ssh/file_manager/recent', file);
+ const response = await sshHostApi.post('/file_manager/recent', file);
return response.data;
} catch (error) {
- throw error;
+ handleApiError(error, 'add recent file');
}
}
-export async function removeFileManagerRecent(file: {
- name: string;
- path: string;
- isSSH: boolean;
- sshSessionId?: string;
- hostId: number
-}): Promise {
+export async function removeFileManagerRecent(file: FileManagerOperation): Promise {
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;
} catch (error) {
- throw error;
+ handleApiError(error, 'remove recent file');
}
}
export async function getFileManagerPinned(hostId: number): Promise {
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 || [];
} catch (error) {
return [];
}
}
-export async function addFileManagerPinned(file: {
- name: string;
- path: string;
- isSSH: boolean;
- sshSessionId?: string;
- hostId: number
-}): Promise {
+export async function addFileManagerPinned(file: FileManagerOperation): Promise {
try {
- const response = await sshHostApi.post('/ssh/file_manager/pinned', file);
+ const response = await sshHostApi.post('/file_manager/pinned', file);
return response.data;
} catch (error) {
- throw error;
+ handleApiError(error, 'add pinned file');
}
}
-export async function removeFileManagerPinned(file: {
- name: string;
- path: string;
- isSSH: boolean;
- sshSessionId?: string;
- hostId: number
-}): Promise {
+export async function removeFileManagerPinned(file: FileManagerOperation): Promise {
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;
} catch (error) {
- throw error;
+ handleApiError(error, 'remove pinned file');
}
}
export async function getFileManagerShortcuts(hostId: number): Promise {
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 || [];
} catch (error) {
return [];
}
}
-export async function addFileManagerShortcut(shortcut: {
- name: string;
- path: string;
- isSSH: boolean;
- sshSessionId?: string;
- hostId: number
-}): Promise {
+export async function addFileManagerShortcut(shortcut: FileManagerOperation): Promise {
try {
- const response = await sshHostApi.post('/ssh/file_manager/shortcuts', shortcut);
+ const response = await sshHostApi.post('/file_manager/shortcuts', shortcut);
return response.data;
} catch (error) {
- throw error;
+ handleApiError(error, 'add shortcut');
}
}
-export async function removeFileManagerShortcut(shortcut: {
- name: string;
- path: string;
- isSSH: boolean;
- sshSessionId?: string;
- hostId: number
-}): Promise {
+export async function removeFileManagerShortcut(shortcut: FileManagerOperation): Promise {
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;
} catch (error) {
- throw error;
+ handleApiError(error, 'remove shortcut');
}
}
+// ============================================================================
+// SSH FILE OPERATIONS
+// ============================================================================
+
export async function connectSSH(sessionId: string, config: {
ip: string;
port: number;
@@ -488,61 +557,61 @@ export async function connectSSH(sessionId: string, config: {
keyPassword?: string;
}): Promise {
try {
- const response = await fileManagerApi.post('/ssh/file_manager/ssh/connect', {
+ const response = await fileManagerApi.post('/ssh/connect', {
sessionId,
...config
});
return response.data;
} catch (error) {
- throw error;
+ handleApiError(error, 'connect SSH');
}
}
export async function disconnectSSH(sessionId: string): Promise {
try {
- const response = await fileManagerApi.post('/ssh/file_manager/ssh/disconnect', {sessionId});
+ const response = await fileManagerApi.post('/ssh/disconnect', { sessionId });
return response.data;
} catch (error) {
- throw error;
+ handleApiError(error, 'disconnect SSH');
}
}
export async function getSSHStatus(sessionId: string): Promise<{ connected: boolean }> {
try {
- const response = await fileManagerApi.get('/ssh/file_manager/ssh/status', {
- params: {sessionId}
+ const response = await fileManagerApi.get('/ssh/status', {
+ params: { sessionId }
});
return response.data;
} catch (error) {
- throw error;
+ handleApiError(error, 'get SSH status');
}
}
export async function listSSHFiles(sessionId: string, path: string): Promise {
try {
- const response = await fileManagerApi.get('/ssh/file_manager/ssh/listFiles', {
- params: {sessionId, path}
+ const response = await fileManagerApi.get('/ssh/listFiles', {
+ params: { sessionId, path }
});
return response.data || [];
} catch (error) {
- throw error;
+ handleApiError(error, 'list SSH files');
}
}
export async function readSSHFile(sessionId: string, path: string): Promise<{ content: string; path: string }> {
try {
- const response = await fileManagerApi.get('/ssh/file_manager/ssh/readFile', {
- params: {sessionId, path}
+ const response = await fileManagerApi.get('/ssh/readFile', {
+ params: { sessionId, path }
});
return response.data;
} catch (error) {
- throw error;
+ handleApiError(error, 'read SSH file');
}
}
export async function writeSSHFile(sessionId: string, path: string, content: string): Promise {
try {
- const response = await fileManagerApi.post('/ssh/file_manager/ssh/writeFile', {
+ const response = await fileManagerApi.post('/ssh/writeFile', {
sessionId,
path,
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');
}
} catch (error) {
- throw error;
+ handleApiError(error, 'write SSH file');
}
}
export async function uploadSSHFile(sessionId: string, path: string, fileName: string, content: string): Promise {
try {
- const response = await fileManagerApi.post('/ssh/file_manager/ssh/uploadFile', {
+ const response = await fileManagerApi.post('/ssh/uploadFile', {
sessionId,
path,
fileName,
@@ -568,13 +637,13 @@ export async function uploadSSHFile(sessionId: string, path: string, fileName: s
});
return response.data;
} catch (error) {
- throw error;
+ handleApiError(error, 'upload SSH file');
}
}
export async function createSSHFile(sessionId: string, path: string, fileName: string, content: string = ''): Promise {
try {
- const response = await fileManagerApi.post('/ssh/file_manager/ssh/createFile', {
+ const response = await fileManagerApi.post('/ssh/createFile', {
sessionId,
path,
fileName,
@@ -582,26 +651,26 @@ export async function createSSHFile(sessionId: string, path: string, fileName: s
});
return response.data;
} catch (error) {
- throw error;
+ handleApiError(error, 'create SSH file');
}
}
export async function createSSHFolder(sessionId: string, path: string, folderName: string): Promise {
try {
- const response = await fileManagerApi.post('/ssh/file_manager/ssh/createFolder', {
+ const response = await fileManagerApi.post('/ssh/createFolder', {
sessionId,
path,
folderName
});
return response.data;
} catch (error) {
- throw error;
+ handleApiError(error, 'create SSH folder');
}
}
export async function deleteSSHItem(sessionId: string, path: string, isDirectory: boolean): Promise {
try {
- const response = await fileManagerApi.delete('/ssh/file_manager/ssh/deleteItem', {
+ const response = await fileManagerApi.delete('/ssh/deleteItem', {
data: {
sessionId,
path,
@@ -610,31 +679,33 @@ export async function deleteSSHItem(sessionId: string, path: string, isDirectory
});
return response.data;
} catch (error) {
- throw error;
+ handleApiError(error, 'delete SSH item');
}
}
export async function renameSSHItem(sessionId: string, oldPath: string, newName: string): Promise {
try {
- const response = await fileManagerApi.put('/ssh/file_manager/ssh/renameItem', {
+ const response = await fileManagerApi.put('/ssh/renameItem', {
sessionId,
oldPath,
newName
});
return response.data;
} catch (error) {
- throw error;
+ handleApiError(error, 'rename SSH item');
}
}
-export {sshHostApi, tunnelApi, fileManagerApi};
+// ============================================================================
+// SERVER STATISTICS
+// ============================================================================
export async function getAllServerStatuses(): Promise> {
try {
const response = await statsApi.get('/status');
return response.data || {};
} catch (error) {
- throw error;
+ handleApiError(error, 'fetch server statuses');
}
}
@@ -643,7 +714,7 @@ export async function getServerStatusById(id: number): Promise {
const response = await statsApi.get(`/status/${id}`);
return response.data;
} catch (error) {
- throw error;
+ handleApiError(error, 'fetch server status');
}
}
@@ -652,6 +723,231 @@ export async function getServerMetricsById(id: number): Promise {
const response = await statsApi.get(`/metrics/${id}`);
return response.data;
} catch (error) {
- throw error;
+ handleApiError(error, 'fetch server metrics');
+ }
+}
+
+// ============================================================================
+// AUTHENTICATION
+// ============================================================================
+
+export async function registerUser(username: string, password: string): Promise {
+ 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 {
+ try {
+ const response = await authApi.post('/users/login', { username, password });
+ return response.data;
+ } catch (error) {
+ handleApiError(error, 'login user');
+ }
+}
+
+export async function getUserInfo(): Promise {
+ 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 {
+ try {
+ const response = await authApi.get('/users/oidc-config');
+ return response.data;
+ } catch (error) {
+ handleApiError(error, 'fetch OIDC config');
+ }
+}
+
+export async function getUserCount(): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ try {
+ const response = await authApi.get('/users/db-health');
+ return response.data;
+ } catch (error) {
+ handleApiError(error, 'check database health');
}
}
\ No newline at end of file