Merge remote-tracking branch 'origin/main'
40
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "daily"
|
||||||
|
groups:
|
||||||
|
dev-patch-updates:
|
||||||
|
dependency-type: "development"
|
||||||
|
update-types:
|
||||||
|
- "patch"
|
||||||
|
dev-minor-updates:
|
||||||
|
dependency-type: "development"
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
prod-patch-updates:
|
||||||
|
dependency-type: "production"
|
||||||
|
update-types:
|
||||||
|
- "patch"
|
||||||
|
prod-minor-updates:
|
||||||
|
dependency-type: "production"
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
|
||||||
|
- package-ecosystem: "docker"
|
||||||
|
directory: "/docker"
|
||||||
|
schedule:
|
||||||
|
interval: "daily"
|
||||||
|
groups:
|
||||||
|
patch-updates:
|
||||||
|
update-types:
|
||||||
|
- "patch"
|
||||||
|
minor-updates:
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
8
.github/workflows/docker-image.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ jobs:
|
|||||||
network=host
|
network=host
|
||||||
|
|
||||||
- name: Cache npm dependencies
|
- name: Cache npm dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.npm
|
~/.npm
|
||||||
@@ -48,7 +48,7 @@ jobs:
|
|||||||
${{ runner.os }}-node-
|
${{ runner.os }}-node-
|
||||||
|
|
||||||
- name: Cache Docker layers
|
- name: Cache Docker layers
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: /tmp/.buildx-cache
|
path: /tmp/.buildx-cache
|
||||||
key: ${{ runner.os }}-buildx-${{ github.ref_name }}-${{ hashFiles('docker/Dockerfile') }}
|
key: ${{ runner.os }}-buildx-${{ github.ref_name }}-${{ hashFiles('docker/Dockerfile') }}
|
||||||
@@ -78,7 +78,7 @@ jobs:
|
|||||||
echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV
|
echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Build and Push Multi-Arch Docker Image
|
- name: Build and Push Multi-Arch Docker Image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./docker/Dockerfile
|
file: ./docker/Dockerfile
|
||||||
|
|||||||
22
README.md
@@ -13,33 +13,39 @@
|
|||||||
[](#)
|
[](#)
|
||||||
[](#)
|
[](#)
|
||||||
|
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/LukeGus/Termix">
|
<a href="https://github.com/LukeGus/Termix">
|
||||||
<img alt="Termix Banner" src=./public/icon.svg style="width: 250px; height: auto;"> </a>
|
<img alt="Termix Banner" src=./repo-images/HeaderImage.png style="width: auto; height: auto;"> </a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
If you would like, you can support the project here!\
|
If you would like, you can support the project here!\
|
||||||
[](https://github.com/sponsors/LukeGus)
|
[](https://github.com/sponsors/LukeGus)
|
||||||
|
|
||||||
# Overview
|
# Overview
|
||||||
Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides a web-based solution for managing your servers and infrastructure through a single, intuitive interface. Termix offers SSH terminal access, SSH tunneling capabilities, and remote file configuration editing, with many more tools to come.
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/LukeGus/Termix">
|
||||||
|
<img alt="Termix Banner" src=./public/icon.svg style="width: 250px; height: 250px;"> </a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides a web-based solution for managing your servers and infrastructure through a single, intuitive interface. Termix offers SSH terminal access, SSH tunneling capabilities, and remote file editing, with many more tools to come.
|
||||||
|
|
||||||
# Features
|
# Features
|
||||||
- **SSH Terminal Access** - Full-featured terminal with split-screen support (up to 4 panels) and tab system
|
- **SSH Terminal Access** - Full-featured terminal with split-screen support (up to 4 panels) and tab system
|
||||||
- **SSH Tunnel Management** - Create and manage SSH tunnels with automatic reconnection and health monitoring
|
- **SSH Tunnel Management** - Create and manage SSH tunnels with automatic reconnection and health monitoring
|
||||||
- **Remote Config Editor** - Edit files directly on remote servers with syntax highlighting and file management
|
- **Remote File Editor** - Edit files directly on remote servers with syntax highlighting, file management features (uploading, removing, renaming, deleting files)
|
||||||
- **SSH Host Manager** - Save, organize, and manage your SSH connections with tags and folders
|
- **SSH Host Manager** - Save, organize, and manage your SSH connections with tags and folders
|
||||||
|
- **Server Stats** - View CPU, memory, and HDD usage on any SSH server
|
||||||
- **User Authentication** - Secure user management with admin controls and OIDC support with more auth types planned
|
- **User Authentication** - Secure user management with admin controls and OIDC support with more auth types planned
|
||||||
- **Modern UI** - Clean interface built with React, Tailwind CSS, and the amazing Shadcn
|
- **Modern UI** - Clean interface built with React, Tailwind CSS, and Shadcn
|
||||||
|
|
||||||
# Planned Features
|
# Planned Features
|
||||||
- **Improved Admin Control** - Ability to manage admins, and give more fine-grained control over their permissions, share hosts, reset passwords, delete accounts, etc
|
- **Improved Admin Control** - Give more fine-grained control over user and admin permissions, share hosts, etc
|
||||||
- **More auth types** - Add 2FA, TOTP, etc
|
- **More auth types** - Add 2FA, TOTP, etc
|
||||||
- **Theming** - Modify themeing for all tools
|
- **Theming** - Modify themeing for all tools
|
||||||
- **Improved SFTP Support** - Ability to manage files easier with the config editor by uploading, creating, and removing files
|
|
||||||
- **Improved Terminal Support** - Add more terminal protocols such as VNC and RDP (anyone who has experience in integrating RDP into a web-application similar to Apache Guacamole, please contact me by creating an issue)
|
- **Improved Terminal Support** - Add more terminal protocols such as VNC and RDP (anyone who has experience in integrating RDP into a web-application similar to Apache Guacamole, please contact me by creating an issue)
|
||||||
|
- **Mobile Support** - Support a mobile app or version of the Termix website to manage servers from your phone
|
||||||
|
|
||||||
# Installation
|
# Installation
|
||||||
Visit the Termix [Docs](https://docs.termix.site/docs) for more information on how to install Termix. Otherwise, view a sample docker-compose file here:
|
Visit the Termix [Docs](https://docs.termix.site/docs) for more information on how to install Termix. Otherwise, view a sample docker-compose file here:
|
||||||
@@ -78,7 +84,7 @@ If you need help with Termix, you can join the [Discord](https://discord.gg/jVQG
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<video src="https://github.com/user-attachments/assets/0f95495d-c5db-48f5-b18b-9ab48bb10d31" width="800" controls>
|
<video src="https://github.com/user-attachments/assets/f9caa061-10dc-4173-ae7d-c6d42f05cf56" width="800" controls>
|
||||||
Your browser does not support the video tag.
|
Your browser does not support the video tag.
|
||||||
</video>
|
</video>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Stage 1: Install dependencies and build frontend
|
# Stage 1: Install dependencies and build frontend
|
||||||
FROM node:18-alpine AS deps
|
FROM node:24-alpine AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apk add --no-cache python3 make g++
|
RUN apk add --no-cache python3 make g++
|
||||||
@@ -26,7 +26,7 @@ COPY . .
|
|||||||
RUN npm run build:backend
|
RUN npm run build:backend
|
||||||
|
|
||||||
# Stage 4: Production dependencies
|
# Stage 4: Production dependencies
|
||||||
FROM node:18-alpine AS production-deps
|
FROM node:24-alpine AS production-deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
@@ -35,7 +35,7 @@ RUN npm ci --only=production --ignore-scripts --force && \
|
|||||||
npm cache clean --force
|
npm cache clean --force
|
||||||
|
|
||||||
# Stage 5: Build native modules
|
# Stage 5: Build native modules
|
||||||
FROM node:18-alpine AS native-builder
|
FROM node:24-alpine AS native-builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apk add --no-cache python3 make g++
|
RUN apk add --no-cache python3 make g++
|
||||||
@@ -46,7 +46,7 @@ RUN npm ci --only=production bcryptjs better-sqlite3 --force && \
|
|||||||
npm cache clean --force
|
npm cache clean --force
|
||||||
|
|
||||||
# Stage 6: Final image
|
# Stage 6: Final image
|
||||||
FROM node:18-alpine
|
FROM node:24-alpine
|
||||||
ENV DATA_DIR=/app/data \
|
ENV DATA_DIR=/app/data \
|
||||||
PORT=8080 \
|
PORT=8080 \
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
@@ -72,7 +72,7 @@ RUN chown -R node:node /app
|
|||||||
|
|
||||||
VOLUME ["/app/data"]
|
VOLUME ["/app/data"]
|
||||||
|
|
||||||
EXPOSE ${PORT} 8081 8082 8083 8084
|
EXPOSE ${PORT} 8081 8082 8083 8084 8085
|
||||||
|
|
||||||
COPY docker/entrypoint.sh /entrypoint.sh
|
COPY docker/entrypoint.sh /entrypoint.sh
|
||||||
RUN chmod +x /entrypoint.sh
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|||||||
@@ -45,7 +45,16 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /ssh/db/ {
|
location /alerts/ {
|
||||||
|
proxy_pass http://127.0.0.1:8081;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /ssh/ {
|
||||||
proxy_pass http://127.0.0.1:8081;
|
proxy_pass http://127.0.0.1:8081;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -62,6 +71,14 @@ http {
|
|||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_cache_bypass $http_upgrade;
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
proxy_connect_timeout 75s;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
@@ -76,7 +93,34 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /ssh/config_editor/ {
|
location /ssh/file_manager/recent {
|
||||||
|
proxy_pass http://127.0.0.1:8081;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /ssh/file_manager/pinned {
|
||||||
|
proxy_pass http://127.0.0.1:8081;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /ssh/file_manager/shortcuts {
|
||||||
|
proxy_pass http://127.0.0.1:8081;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /ssh/file_manager/ssh/ {
|
||||||
proxy_pass http://127.0.0.1:8084;
|
proxy_pass http://127.0.0.1:8084;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -85,8 +129,17 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/ssh/config_editor/(recent|pinned|shortcuts) {
|
location /status/ {
|
||||||
proxy_pass http://127.0.0.1:8081;
|
proxy_pass http://127.0.0.1:8085;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /metrics/ {
|
||||||
|
proxy_pass http://127.0.0.1:8085;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|||||||
531
package-lock.json
generated
@@ -13,10 +13,11 @@
|
|||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
"@radix-ui/react-checkbox": "^1.3.2",
|
"@radix-ui/react-checkbox": "^1.3.2",
|
||||||
"@radix-ui/react-collapsible": "^1.1.11",
|
"@radix-ui/react-collapsible": "^1.1.11",
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-popover": "^1.1.14",
|
"@radix-ui/react-popover": "^1.1.14",
|
||||||
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||||
"@radix-ui/react-select": "^2.2.5",
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
@@ -24,7 +25,7 @@
|
|||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-switch": "^1.2.5",
|
"@radix-ui/react-switch": "^1.2.5",
|
||||||
"@radix-ui/react-tabs": "^1.1.12",
|
"@radix-ui/react-tabs": "^1.1.12",
|
||||||
"@radix-ui/react-tooltip": "^1.2.7",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
@@ -55,12 +56,14 @@
|
|||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"nanoid": "^5.1.5",
|
"nanoid": "^5.1.5",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"node-fetch": "^3.3.2",
|
"node-fetch": "^3.3.2",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-hook-form": "^7.60.0",
|
"react-hook-form": "^7.60.0",
|
||||||
"react-resizable-panels": "^3.0.3",
|
"react-resizable-panels": "^3.0.3",
|
||||||
"react-xtermjs": "^1.0.10",
|
"react-xtermjs": "^1.0.10",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"ssh2": "^1.16.0",
|
"ssh2": "^1.16.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
@@ -69,27 +72,27 @@
|
|||||||
"zod": "^4.0.5"
|
"zod": "^4.0.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.30.1",
|
"@eslint/js": "^9.34.0",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.3",
|
"@types/express": "^5.0.3",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^24.0.13",
|
"@types/node": "^24.3.0",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"@types/ssh2": "^1.15.5",
|
"@types/ssh2": "^1.15.5",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"@vitejs/plugin-react-swc": "^3.10.2",
|
"@vitejs/plugin-react-swc": "^3.10.2",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"eslint": "^9.30.1",
|
"eslint": "^9.34.0",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.20",
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
"globals": "^16.3.0",
|
"globals": "^16.3.0",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tw-animate-css": "^1.3.5",
|
"tw-animate-css": "^1.3.5",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.9.2",
|
||||||
"typescript-eslint": "^8.35.1",
|
"typescript-eslint": "^8.40.0",
|
||||||
"vite": "^7.0.4"
|
"vite": "^7.1.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@ampproject/remapping": {
|
"node_modules/@ampproject/remapping": {
|
||||||
@@ -1020,9 +1023,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/config-helpers": {
|
"node_modules/@eslint/config-helpers": {
|
||||||
"version": "0.3.0",
|
"version": "0.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz",
|
||||||
"integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==",
|
"integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1030,9 +1033,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/core": {
|
"node_modules/@eslint/core": {
|
||||||
"version": "0.15.1",
|
"version": "0.15.2",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz",
|
||||||
"integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==",
|
"integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1080,9 +1083,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/js": {
|
"node_modules/@eslint/js": {
|
||||||
"version": "9.31.0",
|
"version": "9.34.0",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.31.0.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.34.0.tgz",
|
||||||
"integrity": "sha512-LOm5OVt7D4qiKCqoiPbA7LWmI+tbw1VbTUowBcUMgQSuM6poJufkFkYDcQpo5KfgD39TnNySV26QjOh7VFpSyw==",
|
"integrity": "sha512-EoyvqQnBNsV1CWaEJ559rxXL4c8V92gxirbawSmVUOWXlsRxxQXl6LmCpdUblgxgSkDIqKnhzba2SjRTI/A5Rw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1103,13 +1106,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/plugin-kit": {
|
"node_modules/@eslint/plugin-kit": {
|
||||||
"version": "0.3.3",
|
"version": "0.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz",
|
||||||
"integrity": "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==",
|
"integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint/core": "^0.15.1",
|
"@eslint/core": "^0.15.2",
|
||||||
"levn": "^0.4.1"
|
"levn": "^0.4.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1739,20 +1742,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-dialog": {
|
"node_modules/@radix-ui/react-dialog": {
|
||||||
"version": "1.1.14",
|
"version": "1.1.15",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.14.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
|
||||||
"integrity": "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==",
|
"integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/primitive": "1.1.2",
|
"@radix-ui/primitive": "1.1.3",
|
||||||
"@radix-ui/react-compose-refs": "1.1.2",
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
"@radix-ui/react-context": "1.1.2",
|
"@radix-ui/react-context": "1.1.2",
|
||||||
"@radix-ui/react-dismissable-layer": "1.1.10",
|
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||||
"@radix-ui/react-focus-guards": "1.1.2",
|
"@radix-ui/react-focus-guards": "1.1.3",
|
||||||
"@radix-ui/react-focus-scope": "1.1.7",
|
"@radix-ui/react-focus-scope": "1.1.7",
|
||||||
"@radix-ui/react-id": "1.1.1",
|
"@radix-ui/react-id": "1.1.1",
|
||||||
"@radix-ui/react-portal": "1.1.9",
|
"@radix-ui/react-portal": "1.1.9",
|
||||||
"@radix-ui/react-presence": "1.1.4",
|
"@radix-ui/react-presence": "1.1.5",
|
||||||
"@radix-ui/react-primitive": "2.1.3",
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
"@radix-ui/react-slot": "1.2.3",
|
"@radix-ui/react-slot": "1.2.3",
|
||||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||||
@@ -1774,6 +1777,78 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/primitive": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": {
|
||||||
|
"version": "1.1.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
|
||||||
|
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||||
|
"@radix-ui/react-use-escape-keydown": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-guards": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-presence": {
|
||||||
|
"version": "1.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
|
||||||
|
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-direction": {
|
"node_modules/@radix-ui/react-direction": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
|
||||||
@@ -2106,6 +2181,30 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-progress": {
|
||||||
|
"version": "1.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz",
|
||||||
|
"integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-roving-focus": {
|
"node_modules/@radix-ui/react-roving-focus": {
|
||||||
"version": "1.1.10",
|
"version": "1.1.10",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.10.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.10.tgz",
|
||||||
@@ -2345,19 +2444,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-tooltip": {
|
"node_modules/@radix-ui/react-tooltip": {
|
||||||
"version": "1.2.7",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
|
||||||
"integrity": "sha512-Ap+fNYwKTYJ9pzqW+Xe2HtMRbQ/EeWkj2qykZ6SuEV4iS/o1bZI5ssJbk4D2r8XuDuOBVz/tIx2JObtuqU+5Zw==",
|
"integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/primitive": "1.1.2",
|
"@radix-ui/primitive": "1.1.3",
|
||||||
"@radix-ui/react-compose-refs": "1.1.2",
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
"@radix-ui/react-context": "1.1.2",
|
"@radix-ui/react-context": "1.1.2",
|
||||||
"@radix-ui/react-dismissable-layer": "1.1.10",
|
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||||
"@radix-ui/react-id": "1.1.1",
|
"@radix-ui/react-id": "1.1.1",
|
||||||
"@radix-ui/react-popper": "1.2.7",
|
"@radix-ui/react-popper": "1.2.8",
|
||||||
"@radix-ui/react-portal": "1.1.9",
|
"@radix-ui/react-portal": "1.1.9",
|
||||||
"@radix-ui/react-presence": "1.1.4",
|
"@radix-ui/react-presence": "1.1.5",
|
||||||
"@radix-ui/react-primitive": "2.1.3",
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
"@radix-ui/react-slot": "1.2.3",
|
"@radix-ui/react-slot": "1.2.3",
|
||||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||||
@@ -2378,6 +2477,95 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/primitive": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-dismissable-layer": {
|
||||||
|
"version": "1.1.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
|
||||||
|
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||||
|
"@radix-ui/react-use-escape-keydown": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-popper": {
|
||||||
|
"version": "1.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
|
||||||
|
"integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/react-dom": "^2.0.0",
|
||||||
|
"@radix-ui/react-arrow": "1.1.7",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1",
|
||||||
|
"@radix-ui/react-use-rect": "1.1.1",
|
||||||
|
"@radix-ui/react-use-size": "1.1.1",
|
||||||
|
"@radix-ui/rect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-presence": {
|
||||||
|
"version": "1.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
|
||||||
|
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||||
@@ -3337,6 +3525,60 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||||
|
"version": "1.4.3",
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/wasi-threads": "1.0.2",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.4.3",
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||||
|
"version": "0.2.11",
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/core": "^1.4.3",
|
||||||
|
"@emnapi/runtime": "^1.4.3",
|
||||||
|
"@tybys/wasm-util": "^0.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||||
|
"version": "0.9.0",
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||||
|
"version": "2.8.0",
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "0BSD",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
"version": "4.1.11",
|
"version": "4.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz",
|
||||||
@@ -3532,12 +3774,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.0.13",
|
"version": "24.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz",
|
||||||
"integrity": "sha512-Qm9OYVOFHFYg3wJoTSrz80hoec5Lia/dPp84do3X7dZvLikQvM1YpmvTBEdIr/e+U8HTkFjLHLnl78K/qjf+jQ==",
|
"integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.8.0"
|
"undici-types": "~7.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/qs": {
|
"node_modules/@types/qs": {
|
||||||
@@ -3631,17 +3873,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.37.0",
|
"version": "8.40.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.37.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.40.0.tgz",
|
||||||
"integrity": "sha512-jsuVWeIkb6ggzB+wPCsR4e6loj+rM72ohW6IBn2C+5NCvfUVY8s33iFPySSVXqtm5Hu29Ne/9bnA0JmyLmgenA==",
|
"integrity": "sha512-w/EboPlBwnmOBtRbiOvzjD+wdiZdgFeo17lkltrtn7X37vagKKWJABvyfsJXTlHe6XBzugmYgd4A4nW+k8Mixw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/regexpp": "^4.10.0",
|
"@eslint-community/regexpp": "^4.10.0",
|
||||||
"@typescript-eslint/scope-manager": "8.37.0",
|
"@typescript-eslint/scope-manager": "8.40.0",
|
||||||
"@typescript-eslint/type-utils": "8.37.0",
|
"@typescript-eslint/type-utils": "8.40.0",
|
||||||
"@typescript-eslint/utils": "8.37.0",
|
"@typescript-eslint/utils": "8.40.0",
|
||||||
"@typescript-eslint/visitor-keys": "8.37.0",
|
"@typescript-eslint/visitor-keys": "8.40.0",
|
||||||
"graphemer": "^1.4.0",
|
"graphemer": "^1.4.0",
|
||||||
"ignore": "^7.0.0",
|
"ignore": "^7.0.0",
|
||||||
"natural-compare": "^1.4.0",
|
"natural-compare": "^1.4.0",
|
||||||
@@ -3655,9 +3897,9 @@
|
|||||||
"url": "https://opencollective.com/typescript-eslint"
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@typescript-eslint/parser": "^8.37.0",
|
"@typescript-eslint/parser": "^8.40.0",
|
||||||
"eslint": "^8.57.0 || ^9.0.0",
|
"eslint": "^8.57.0 || ^9.0.0",
|
||||||
"typescript": ">=4.8.4 <5.9.0"
|
"typescript": ">=4.8.4 <6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
|
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
|
||||||
@@ -3671,16 +3913,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/parser": {
|
"node_modules/@typescript-eslint/parser": {
|
||||||
"version": "8.37.0",
|
"version": "8.40.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.37.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.40.0.tgz",
|
||||||
"integrity": "sha512-kVIaQE9vrN9RLCQMQ3iyRlVJpTiDUY6woHGb30JDkfJErqrQEmtdWH3gV0PBAfGZgQXoqzXOO0T3K6ioApbbAA==",
|
"integrity": "sha512-jCNyAuXx8dr5KJMkecGmZ8KI61KBUhkCob+SD+C+I5+Y1FWI2Y3QmY4/cxMCC5WAsZqoEtEETVhUiUMIGCf6Bw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.37.0",
|
"@typescript-eslint/scope-manager": "8.40.0",
|
||||||
"@typescript-eslint/types": "8.37.0",
|
"@typescript-eslint/types": "8.40.0",
|
||||||
"@typescript-eslint/typescript-estree": "8.37.0",
|
"@typescript-eslint/typescript-estree": "8.40.0",
|
||||||
"@typescript-eslint/visitor-keys": "8.37.0",
|
"@typescript-eslint/visitor-keys": "8.40.0",
|
||||||
"debug": "^4.3.4"
|
"debug": "^4.3.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3692,18 +3934,18 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"eslint": "^8.57.0 || ^9.0.0",
|
"eslint": "^8.57.0 || ^9.0.0",
|
||||||
"typescript": ">=4.8.4 <5.9.0"
|
"typescript": ">=4.8.4 <6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/project-service": {
|
"node_modules/@typescript-eslint/project-service": {
|
||||||
"version": "8.37.0",
|
"version": "8.40.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.37.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.40.0.tgz",
|
||||||
"integrity": "sha512-BIUXYsbkl5A1aJDdYJCBAo8rCEbAvdquQ8AnLb6z5Lp1u3x5PNgSSx9A/zqYc++Xnr/0DVpls8iQ2cJs/izTXA==",
|
"integrity": "sha512-/A89vz7Wf5DEXsGVvcGdYKbVM9F7DyFXj52lNYUDS1L9yJfqjW/fIp5PgMuEJL/KeqVTe2QSbXAGUZljDUpArw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/tsconfig-utils": "^8.37.0",
|
"@typescript-eslint/tsconfig-utils": "^8.40.0",
|
||||||
"@typescript-eslint/types": "^8.37.0",
|
"@typescript-eslint/types": "^8.40.0",
|
||||||
"debug": "^4.3.4"
|
"debug": "^4.3.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3714,18 +3956,18 @@
|
|||||||
"url": "https://opencollective.com/typescript-eslint"
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": ">=4.8.4 <5.9.0"
|
"typescript": ">=4.8.4 <6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/scope-manager": {
|
"node_modules/@typescript-eslint/scope-manager": {
|
||||||
"version": "8.37.0",
|
"version": "8.40.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.37.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.40.0.tgz",
|
||||||
"integrity": "sha512-0vGq0yiU1gbjKob2q691ybTg9JX6ShiVXAAfm2jGf3q0hdP6/BruaFjL/ManAR/lj05AvYCH+5bbVo0VtzmjOA==",
|
"integrity": "sha512-y9ObStCcdCiZKzwqsE8CcpyuVMwRouJbbSrNuThDpv16dFAj429IkM6LNb1dZ2m7hK5fHyzNcErZf7CEeKXR4w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.37.0",
|
"@typescript-eslint/types": "8.40.0",
|
||||||
"@typescript-eslint/visitor-keys": "8.37.0"
|
"@typescript-eslint/visitor-keys": "8.40.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@@ -3736,9 +3978,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||||
"version": "8.37.0",
|
"version": "8.40.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.37.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.40.0.tgz",
|
||||||
"integrity": "sha512-1/YHvAVTimMM9mmlPvTec9NP4bobA1RkDbMydxG8omqwJJLEW/Iy2C4adsAESIXU3WGLXFHSZUU+C9EoFWl4Zg==",
|
"integrity": "sha512-jtMytmUaG9d/9kqSl/W3E3xaWESo4hFDxAIHGVW/WKKtQhesnRIJSAJO6XckluuJ6KDB5woD1EiqknriCtAmcw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3749,19 +3991,19 @@
|
|||||||
"url": "https://opencollective.com/typescript-eslint"
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": ">=4.8.4 <5.9.0"
|
"typescript": ">=4.8.4 <6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/type-utils": {
|
"node_modules/@typescript-eslint/type-utils": {
|
||||||
"version": "8.37.0",
|
"version": "8.40.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.37.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.40.0.tgz",
|
||||||
"integrity": "sha512-SPkXWIkVZxhgwSwVq9rqj/4VFo7MnWwVaRNznfQDc/xPYHjXnPfLWn+4L6FF1cAz6e7dsqBeMawgl7QjUMj4Ow==",
|
"integrity": "sha512-eE60cK4KzAc6ZrzlJnflXdrMqOBaugeukWICO2rB0KNvwdIMaEaYiywwHMzA1qFpTxrLhN9Lp4E/00EgWcD3Ow==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.37.0",
|
"@typescript-eslint/types": "8.40.0",
|
||||||
"@typescript-eslint/typescript-estree": "8.37.0",
|
"@typescript-eslint/typescript-estree": "8.40.0",
|
||||||
"@typescript-eslint/utils": "8.37.0",
|
"@typescript-eslint/utils": "8.40.0",
|
||||||
"debug": "^4.3.4",
|
"debug": "^4.3.4",
|
||||||
"ts-api-utils": "^2.1.0"
|
"ts-api-utils": "^2.1.0"
|
||||||
},
|
},
|
||||||
@@ -3774,13 +4016,13 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"eslint": "^8.57.0 || ^9.0.0",
|
"eslint": "^8.57.0 || ^9.0.0",
|
||||||
"typescript": ">=4.8.4 <5.9.0"
|
"typescript": ">=4.8.4 <6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/types": {
|
"node_modules/@typescript-eslint/types": {
|
||||||
"version": "8.37.0",
|
"version": "8.40.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.37.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.40.0.tgz",
|
||||||
"integrity": "sha512-ax0nv7PUF9NOVPs+lmQ7yIE7IQmAf8LGcXbMvHX5Gm+YJUYNAl340XkGnrimxZ0elXyoQJuN5sbg6C4evKA4SQ==",
|
"integrity": "sha512-ETdbFlgbAmXHyFPwqUIYrfc12ArvpBhEVgGAxVYSwli26dn8Ko+lIo4Su9vI9ykTZdJn+vJprs/0eZU0YMAEQg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3792,16 +4034,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree": {
|
"node_modules/@typescript-eslint/typescript-estree": {
|
||||||
"version": "8.37.0",
|
"version": "8.40.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.37.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.40.0.tgz",
|
||||||
"integrity": "sha512-zuWDMDuzMRbQOM+bHyU4/slw27bAUEcKSKKs3hcv2aNnc/tvE/h7w60dwVw8vnal2Pub6RT1T7BI8tFZ1fE+yg==",
|
"integrity": "sha512-k1z9+GJReVVOkc1WfVKs1vBrR5MIKKbdAjDTPvIK3L8De6KbFfPFt6BKpdkdk7rZS2GtC/m6yI5MYX+UsuvVYQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/project-service": "8.37.0",
|
"@typescript-eslint/project-service": "8.40.0",
|
||||||
"@typescript-eslint/tsconfig-utils": "8.37.0",
|
"@typescript-eslint/tsconfig-utils": "8.40.0",
|
||||||
"@typescript-eslint/types": "8.37.0",
|
"@typescript-eslint/types": "8.40.0",
|
||||||
"@typescript-eslint/visitor-keys": "8.37.0",
|
"@typescript-eslint/visitor-keys": "8.40.0",
|
||||||
"debug": "^4.3.4",
|
"debug": "^4.3.4",
|
||||||
"fast-glob": "^3.3.2",
|
"fast-glob": "^3.3.2",
|
||||||
"is-glob": "^4.0.3",
|
"is-glob": "^4.0.3",
|
||||||
@@ -3817,7 +4059,7 @@
|
|||||||
"url": "https://opencollective.com/typescript-eslint"
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": ">=4.8.4 <5.9.0"
|
"typescript": ">=4.8.4 <6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||||
@@ -3847,16 +4089,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/utils": {
|
"node_modules/@typescript-eslint/utils": {
|
||||||
"version": "8.37.0",
|
"version": "8.40.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.37.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.40.0.tgz",
|
||||||
"integrity": "sha512-TSFvkIW6gGjN2p6zbXo20FzCABbyUAuq6tBvNRGsKdsSQ6a7rnV6ADfZ7f4iI3lIiXc4F4WWvtUfDw9CJ9pO5A==",
|
"integrity": "sha512-Cgzi2MXSZyAUOY+BFwGs17s7ad/7L+gKt6Y8rAVVWS+7o6wrjeFN4nVfTpbE25MNcxyJ+iYUXflbs2xR9h4UBg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.7.0",
|
"@eslint-community/eslint-utils": "^4.7.0",
|
||||||
"@typescript-eslint/scope-manager": "8.37.0",
|
"@typescript-eslint/scope-manager": "8.40.0",
|
||||||
"@typescript-eslint/types": "8.37.0",
|
"@typescript-eslint/types": "8.40.0",
|
||||||
"@typescript-eslint/typescript-estree": "8.37.0"
|
"@typescript-eslint/typescript-estree": "8.40.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@@ -3867,17 +4109,17 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"eslint": "^8.57.0 || ^9.0.0",
|
"eslint": "^8.57.0 || ^9.0.0",
|
||||||
"typescript": ">=4.8.4 <5.9.0"
|
"typescript": ">=4.8.4 <6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/visitor-keys": {
|
"node_modules/@typescript-eslint/visitor-keys": {
|
||||||
"version": "8.37.0",
|
"version": "8.40.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.37.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.40.0.tgz",
|
||||||
"integrity": "sha512-YzfhzcTnZVPiLfP/oeKtDp2evwvHLMe0LOy7oe+hb9KKIumLNohYS9Hgp1ifwpu42YWxhZE8yieggz6JpqO/1w==",
|
"integrity": "sha512-8CZ47QwalyRjsypfwnbI3hKy5gJDPmrkLjkgMxhi0+DZZ2QNx2naS6/hWoVYUHU7LU2zleF68V9miaVZvhFfTA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.37.0",
|
"@typescript-eslint/types": "8.40.0",
|
||||||
"eslint-visitor-keys": "^4.2.1"
|
"eslint-visitor-keys": "^4.2.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -5214,20 +5456,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint": {
|
"node_modules/eslint": {
|
||||||
"version": "9.31.0",
|
"version": "9.34.0",
|
||||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.31.0.tgz",
|
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.34.0.tgz",
|
||||||
"integrity": "sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==",
|
"integrity": "sha512-RNCHRX5EwdrESy3Jc9o8ie8Bog+PeYvvSR8sDGoZxNFTvZ4dlxUB3WzQ3bQMztFrSRODGrLLj8g6OFuGY/aiQg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
"@eslint/config-array": "^0.21.0",
|
"@eslint/config-array": "^0.21.0",
|
||||||
"@eslint/config-helpers": "^0.3.0",
|
"@eslint/config-helpers": "^0.3.1",
|
||||||
"@eslint/core": "^0.15.0",
|
"@eslint/core": "^0.15.2",
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
"@eslint/js": "9.31.0",
|
"@eslint/js": "9.34.0",
|
||||||
"@eslint/plugin-kit": "^0.3.1",
|
"@eslint/plugin-kit": "^0.3.5",
|
||||||
"@humanfs/node": "^0.16.6",
|
"@humanfs/node": "^0.16.6",
|
||||||
"@humanwhocodes/module-importer": "^1.0.1",
|
"@humanwhocodes/module-importer": "^1.0.1",
|
||||||
"@humanwhocodes/retry": "^0.4.2",
|
"@humanwhocodes/retry": "^0.4.2",
|
||||||
@@ -6784,6 +7026,16 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/next-themes": {
|
||||||
|
"version": "0.4.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
|
||||||
|
"integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-abi": {
|
"node_modules/node-abi": {
|
||||||
"version": "3.75.0",
|
"version": "3.75.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz",
|
||||||
@@ -7691,6 +7943,16 @@
|
|||||||
"simple-concat": "^1.0.0"
|
"simple-concat": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sonner": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -8039,9 +8301,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.8.3",
|
"version": "5.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
|
||||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -8053,16 +8315,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/typescript-eslint": {
|
"node_modules/typescript-eslint": {
|
||||||
"version": "8.37.0",
|
"version": "8.40.0",
|
||||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.37.0.tgz",
|
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.40.0.tgz",
|
||||||
"integrity": "sha512-TnbEjzkE9EmcO0Q2zM+GE8NQLItNAJpMmED1BdgoBMYNdqMhzlbqfdSwiRlAzEK2pA9UzVW0gzaaIzXWg2BjfA==",
|
"integrity": "sha512-Xvd2l+ZmFDPEt4oj1QEXzA4A2uUK6opvKu3eGN9aGjB8au02lIVcLyi375w94hHyejTOmzIU77L8ol2sRg9n7Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/eslint-plugin": "8.37.0",
|
"@typescript-eslint/eslint-plugin": "8.40.0",
|
||||||
"@typescript-eslint/parser": "8.37.0",
|
"@typescript-eslint/parser": "8.40.0",
|
||||||
"@typescript-eslint/typescript-estree": "8.37.0",
|
"@typescript-eslint/typescript-estree": "8.40.0",
|
||||||
"@typescript-eslint/utils": "8.37.0"
|
"@typescript-eslint/utils": "8.40.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@@ -8073,13 +8335,13 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"eslint": "^8.57.0 || ^9.0.0",
|
"eslint": "^8.57.0 || ^9.0.0",
|
||||||
"typescript": ">=4.8.4 <5.9.0"
|
"typescript": ">=4.8.4 <6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.8.0",
|
"version": "7.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz",
|
||||||
"integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==",
|
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unpipe": {
|
"node_modules/unpipe": {
|
||||||
@@ -8216,16 +8478,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.0.4",
|
"version": "7.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.3.tgz",
|
||||||
"integrity": "sha512-SkaSguuS7nnmV7mfJ8l81JGBFV7Gvzp8IzgE8A8t23+AxuNX61Q5H1Tpz5efduSN7NHC8nQXD3sKQKZAu5mNEA==",
|
"integrity": "sha512-OOUi5zjkDxYrKhTV3V7iKsoS37VUM7v40+HuwEmcrsf11Cdx9y3DIr2Px6liIcZFwt3XSRpQvFpL3WVy7ApkGw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.6",
|
"fdir": "^6.5.0",
|
||||||
"picomatch": "^4.0.2",
|
"picomatch": "^4.0.3",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"rollup": "^4.40.0",
|
"rollup": "^4.43.0",
|
||||||
"tinyglobby": "^0.2.14"
|
"tinyglobby": "^0.2.14"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -8290,10 +8552,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/fdir": {
|
"node_modules/vite/node_modules/fdir": {
|
||||||
"version": "6.4.6",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
"integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
|
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"picomatch": "^3 || ^4"
|
"picomatch": "^3 || ^4"
|
||||||
},
|
},
|
||||||
@@ -8304,9 +8569,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/picomatch": {
|
"node_modules/vite/node_modules/picomatch": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
|
|||||||
19
package.json
@@ -17,10 +17,11 @@
|
|||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
"@radix-ui/react-checkbox": "^1.3.2",
|
"@radix-ui/react-checkbox": "^1.3.2",
|
||||||
"@radix-ui/react-collapsible": "^1.1.11",
|
"@radix-ui/react-collapsible": "^1.1.11",
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-popover": "^1.1.14",
|
"@radix-ui/react-popover": "^1.1.14",
|
||||||
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||||
"@radix-ui/react-select": "^2.2.5",
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
@@ -28,7 +29,7 @@
|
|||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-switch": "^1.2.5",
|
"@radix-ui/react-switch": "^1.2.5",
|
||||||
"@radix-ui/react-tabs": "^1.1.12",
|
"@radix-ui/react-tabs": "^1.1.12",
|
||||||
"@radix-ui/react-tooltip": "^1.2.7",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
@@ -59,12 +60,14 @@
|
|||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"nanoid": "^5.1.5",
|
"nanoid": "^5.1.5",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"node-fetch": "^3.3.2",
|
"node-fetch": "^3.3.2",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-hook-form": "^7.60.0",
|
"react-hook-form": "^7.60.0",
|
||||||
"react-resizable-panels": "^3.0.3",
|
"react-resizable-panels": "^3.0.3",
|
||||||
"react-xtermjs": "^1.0.10",
|
"react-xtermjs": "^1.0.10",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"ssh2": "^1.16.0",
|
"ssh2": "^1.16.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
@@ -73,26 +76,26 @@
|
|||||||
"zod": "^4.0.5"
|
"zod": "^4.0.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.30.1",
|
"@eslint/js": "^9.34.0",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.3",
|
"@types/express": "^5.0.3",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^24.0.13",
|
"@types/node": "^24.3.0",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"@types/ssh2": "^1.15.5",
|
"@types/ssh2": "^1.15.5",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"@vitejs/plugin-react-swc": "^3.10.2",
|
"@vitejs/plugin-react-swc": "^3.10.2",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"eslint": "^9.30.1",
|
"eslint": "^9.34.0",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.20",
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
"globals": "^16.3.0",
|
"globals": "^16.3.0",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tw-animate-css": "^1.3.5",
|
"tw-animate-css": "^1.3.5",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.9.2",
|
||||||
"typescript-eslint": "^8.35.1",
|
"typescript-eslint": "^8.40.0",
|
||||||
"vite": "^7.0.4"
|
"vite": "^7.1.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
repo-images/HeaderImage.png
Normal file
|
After Width: | Height: | Size: 524 KiB |
|
Before Width: | Height: | Size: 614 KiB After Width: | Height: | Size: 240 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 397 KiB After Width: | Height: | Size: 311 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 115 KiB |
214
src/App.tsx
@@ -1,14 +1,69 @@
|
|||||||
import React from "react"
|
import React, {useState, useEffect} from "react"
|
||||||
|
import {LeftSidebar} from "@/ui/Navigation/LeftSidebar.tsx"
|
||||||
|
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 {TopNavbar} from "@/ui/Navigation/TopNavbar.tsx";
|
||||||
|
import { AdminSettings } from "@/ui/Admin/AdminSettings";
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { getUserInfo } from "@/ui/main-axios.ts";
|
||||||
|
|
||||||
import {Homepage} from "@/apps/Homepage/Homepage.tsx"
|
function getCookie(name: string) {
|
||||||
import {SSH} from "@/apps/SSH/Terminal/SSH.tsx"
|
return document.cookie.split('; ').reduce((r, v) => {
|
||||||
import {SSHTunnel} from "@/apps/SSH/Tunnel/SSHTunnel.tsx";
|
const parts = v.split('=');
|
||||||
import {ConfigEditor} from "@/apps/SSH/Config Editor/ConfigEditor.tsx";
|
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
|
||||||
import {SSHManager} from "@/apps/SSH/Manager/SSHManager.tsx"
|
}, "");
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function setCookie(name: string, value: string, days = 7) {
|
||||||
const [view, setView] = React.useState<string>("homepage")
|
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
||||||
const [mountedViews, setMountedViews] = React.useState<Set<string>>(new Set(["homepage"]))
|
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppContent() {
|
||||||
|
const [view, setView] = useState<string>("homepage")
|
||||||
|
const [mountedViews, setMountedViews] = useState<Set<string>>(new Set(["homepage"]))
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||||
|
const [username, setUsername] = useState<string | null>(null)
|
||||||
|
const [isAdmin, setIsAdmin] = useState(false)
|
||||||
|
const [authLoading, setAuthLoading] = useState(true)
|
||||||
|
const [isTopbarOpen, setIsTopbarOpen] = useState<boolean>(true)
|
||||||
|
const {currentTab, tabs} = useTabs();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkAuth = () => {
|
||||||
|
const jwt = getCookie("jwt");
|
||||||
|
if (jwt) {
|
||||||
|
setAuthLoading(true);
|
||||||
|
getUserInfo()
|
||||||
|
.then((meRes) => {
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
setIsAdmin(!!meRes.is_admin);
|
||||||
|
setUsername(meRes.username || null);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
setIsAdmin(false);
|
||||||
|
setUsername(null);
|
||||||
|
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||||
|
})
|
||||||
|
.finally(() => setAuthLoading(false));
|
||||||
|
} else {
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
setIsAdmin(false);
|
||||||
|
setUsername(null);
|
||||||
|
setAuthLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkAuth()
|
||||||
|
|
||||||
|
const handleStorageChange = () => checkAuth()
|
||||||
|
window.addEventListener('storage', handleStorageChange)
|
||||||
|
|
||||||
|
return () => window.removeEventListener('storage', handleStorageChange)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleSelectView = (nextView: string) => {
|
const handleSelectView = (nextView: string) => {
|
||||||
setMountedViews((prev) => {
|
setMountedViews((prev) => {
|
||||||
@@ -20,37 +75,138 @@ function App() {
|
|||||||
setView(nextView)
|
setView(nextView)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleAuthSuccess = (authData: { isAdmin: boolean; username: string | null; userId: string | null }) => {
|
||||||
|
setIsAuthenticated(true)
|
||||||
|
setIsAdmin(authData.isAdmin)
|
||||||
|
setUsername(authData.username)
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTabData = tabs.find(tab => tab.id === currentTab);
|
||||||
|
const showTerminalView = currentTabData?.type === 'terminal' || currentTabData?.type === 'server' || currentTabData?.type === 'file_manager';
|
||||||
|
const showHome = currentTabData?.type === 'home';
|
||||||
|
const showSshManager = currentTabData?.type === 'ssh_manager';
|
||||||
|
const showAdmin = currentTabData?.type === 'admin';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-svh w-full">
|
<div>
|
||||||
<main className="flex-1 w-full">
|
{!isAuthenticated && !authLoading && (
|
||||||
{mountedViews.has("homepage") && (
|
<div>
|
||||||
<div style={{display: view === "homepage" ? "block" : "none"}}>
|
<div className="absolute inset-0" style={{
|
||||||
<Homepage onSelectView={handleSelectView} />
|
backgroundImage: `linear-gradient(
|
||||||
|
135deg,
|
||||||
|
transparent 0%,
|
||||||
|
transparent 49%,
|
||||||
|
rgba(255, 255, 255, 0.03) 49%,
|
||||||
|
rgba(255, 255, 255, 0.03) 51%,
|
||||||
|
transparent 51%,
|
||||||
|
transparent 100%
|
||||||
|
)`,
|
||||||
|
backgroundSize: '80px 80px'
|
||||||
|
}} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{mountedViews.has("ssh_manager") && (
|
|
||||||
<div style={{display: view === "ssh_manager" ? "block" : "none"}}>
|
{!isAuthenticated && !authLoading && (
|
||||||
<SSHManager onSelectView={handleSelectView} />
|
<div className="fixed inset-0 flex items-center justify-center z-[10000]">
|
||||||
|
<Homepage
|
||||||
|
onSelectView={handleSelectView}
|
||||||
|
isAuthenticated={isAuthenticated}
|
||||||
|
authLoading={authLoading}
|
||||||
|
onAuthSuccess={handleAuthSuccess}
|
||||||
|
isTopbarOpen={isTopbarOpen}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{mountedViews.has("terminal") && (
|
|
||||||
<div style={{display: view === "terminal" ? "block" : "none"}}>
|
{isAuthenticated && (
|
||||||
<SSH onSelectView={handleSelectView} />
|
<LeftSidebar
|
||||||
|
onSelectView={handleSelectView}
|
||||||
|
disabled={!isAuthenticated || authLoading}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
username={username}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-screen w-full"
|
||||||
|
style={{
|
||||||
|
visibility: showTerminalView ? "visible" : "hidden",
|
||||||
|
pointerEvents: showTerminalView ? "auto" : "none",
|
||||||
|
height: showTerminalView ? "100vh" : 0,
|
||||||
|
width: showTerminalView ? "100%" : 0,
|
||||||
|
position: showTerminalView ? "static" : "absolute",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AppView isTopbarOpen={isTopbarOpen} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
{mountedViews.has("tunnel") && (
|
<div
|
||||||
<div style={{display: view === "tunnel" ? "block" : "none"}}>
|
className="h-screen w-full"
|
||||||
<SSHTunnel onSelectView={handleSelectView} />
|
style={{
|
||||||
|
visibility: showHome ? "visible" : "hidden",
|
||||||
|
pointerEvents: showHome ? "auto" : "none",
|
||||||
|
height: showHome ? "100vh" : 0,
|
||||||
|
width: showHome ? "100%" : 0,
|
||||||
|
position: showHome ? "static" : "absolute",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Homepage
|
||||||
|
onSelectView={handleSelectView}
|
||||||
|
isAuthenticated={isAuthenticated}
|
||||||
|
authLoading={authLoading}
|
||||||
|
onAuthSuccess={handleAuthSuccess}
|
||||||
|
isTopbarOpen={isTopbarOpen}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
{mountedViews.has("config_editor") && (
|
<div
|
||||||
<div style={{display: view === "config_editor" ? "block" : "none"}}>
|
className="h-screen w-full"
|
||||||
<ConfigEditor onSelectView={handleSelectView} />
|
style={{
|
||||||
|
visibility: showSshManager ? "visible" : "hidden",
|
||||||
|
pointerEvents: showSshManager ? "auto" : "none",
|
||||||
|
height: showSshManager ? "100vh" : 0,
|
||||||
|
width: showSshManager ? "100%" : 0,
|
||||||
|
position: showSshManager ? "static" : "absolute",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HostManager onSelectView={handleSelectView} isTopbarOpen={isTopbarOpen} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="h-screen w-full"
|
||||||
|
style={{
|
||||||
|
visibility: showAdmin ? "visible" : "hidden",
|
||||||
|
pointerEvents: showAdmin ? "auto" : "none",
|
||||||
|
height: showAdmin ? "100vh" : 0,
|
||||||
|
width: showAdmin ? "100%" : 0,
|
||||||
|
position: showAdmin ? "static" : "absolute",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AdminSettings isTopbarOpen={isTopbarOpen} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TopNavbar isTopbarOpen={isTopbarOpen} setIsTopbarOpen={setIsTopbarOpen}/>
|
||||||
|
</LeftSidebar>
|
||||||
)}
|
)}
|
||||||
</main>
|
<Toaster
|
||||||
|
position="bottom-right"
|
||||||
|
richColors={false}
|
||||||
|
closeButton
|
||||||
|
duration={5000}
|
||||||
|
offset={20}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<TabProvider>
|
||||||
|
<AppContent />
|
||||||
|
</TabProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import {HomepageSidebar} from "@/apps/Homepage/HomepageSidebar.tsx";
|
|
||||||
import React, {useEffect, useState} from "react";
|
|
||||||
import {HomepageAuth} from "@/apps/Homepage/HomepageAuth.tsx";
|
|
||||||
import axios from "axios";
|
|
||||||
import {HomepageUpdateLog} from "@/apps/Homepage/HompageUpdateLog.tsx";
|
|
||||||
|
|
||||||
interface HomepageProps {
|
|
||||||
onSelectView: (view: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setCookie(name: string, value: string, days = 7) {
|
|
||||||
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
|
||||||
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCookie(name: string) {
|
|
||||||
return document.cookie.split('; ').reduce((r, v) => {
|
|
||||||
const parts = v.split('=');
|
|
||||||
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
|
|
||||||
}, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users";
|
|
||||||
|
|
||||||
const API = axios.create({
|
|
||||||
baseURL: apiBase,
|
|
||||||
});
|
|
||||||
|
|
||||||
export function Homepage({onSelectView}: HomepageProps): React.ReactElement {
|
|
||||||
const [loggedIn, setLoggedIn] = useState(false);
|
|
||||||
const [isAdmin, setIsAdmin] = useState(false);
|
|
||||||
const [username, setUsername] = useState<string | null>(null);
|
|
||||||
const [authLoading, setAuthLoading] = useState(true);
|
|
||||||
const [dbError, setDbError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const jwt = getCookie("jwt");
|
|
||||||
if (jwt) {
|
|
||||||
setAuthLoading(true);
|
|
||||||
Promise.all([
|
|
||||||
API.get("/me", {headers: {Authorization: `Bearer ${jwt}`}}),
|
|
||||||
API.get("/db-health")
|
|
||||||
])
|
|
||||||
.then(([meRes]) => {
|
|
||||||
setLoggedIn(true);
|
|
||||||
setIsAdmin(!!meRes.data.is_admin);
|
|
||||||
setUsername(meRes.data.username || null);
|
|
||||||
setDbError(null);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
setLoggedIn(false);
|
|
||||||
setIsAdmin(false);
|
|
||||||
setUsername(null);
|
|
||||||
setCookie("jwt", "", -1);
|
|
||||||
if (err?.response?.data?.error?.includes("Database")) {
|
|
||||||
setDbError("Could not connect to the database. Please try again later.");
|
|
||||||
} else {
|
|
||||||
setDbError(null);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.finally(() => setAuthLoading(false));
|
|
||||||
} else {
|
|
||||||
setAuthLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<HomepageSidebar
|
|
||||||
onSelectView={onSelectView}
|
|
||||||
disabled={!loggedIn || authLoading}
|
|
||||||
isAdmin={isAdmin}
|
|
||||||
username={loggedIn ? username : null}
|
|
||||||
>
|
|
||||||
<div className="w-full min-h-svh grid place-items-center">
|
|
||||||
<div className="flex flex-row items-center justify-center gap-8">
|
|
||||||
<HomepageAuth
|
|
||||||
setLoggedIn={setLoggedIn}
|
|
||||||
setIsAdmin={setIsAdmin}
|
|
||||||
setUsername={setUsername}
|
|
||||||
loggedIn={loggedIn}
|
|
||||||
authLoading={authLoading}
|
|
||||||
dbError={dbError}
|
|
||||||
setDbError={setDbError}
|
|
||||||
/>
|
|
||||||
<HomepageUpdateLog
|
|
||||||
loggedIn={loggedIn}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</HomepageSidebar>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,400 +0,0 @@
|
|||||||
import React, {useState, useEffect} from "react";
|
|
||||||
import {cn} from "@/lib/utils";
|
|
||||||
import {Button} from "@/components/ui/button";
|
|
||||||
import {Input} from "@/components/ui/input";
|
|
||||||
import {Label} from "@/components/ui/label";
|
|
||||||
import {Alert, AlertTitle, AlertDescription} from "@/components/ui/alert";
|
|
||||||
import {Separator} from "@/components/ui/separator";
|
|
||||||
import axios from "axios";
|
|
||||||
|
|
||||||
function setCookie(name: string, value: string, days = 7) {
|
|
||||||
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
|
||||||
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCookie(name: string) {
|
|
||||||
return document.cookie.split('; ').reduce((r, v) => {
|
|
||||||
const parts = v.split('=');
|
|
||||||
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
|
|
||||||
}, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
setIsAdmin: (isAdmin: boolean) => void;
|
|
||||||
setUsername: (username: string | null) => void;
|
|
||||||
loggedIn: boolean;
|
|
||||||
authLoading: boolean;
|
|
||||||
dbError: string | null;
|
|
||||||
setDbError: (error: string | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HomepageAuth({
|
|
||||||
className,
|
|
||||||
setLoggedIn,
|
|
||||||
setIsAdmin,
|
|
||||||
setUsername,
|
|
||||||
loggedIn,
|
|
||||||
authLoading,
|
|
||||||
dbError,
|
|
||||||
setDbError,
|
|
||||||
...props
|
|
||||||
}: HomepageAuthProps) {
|
|
||||||
const [tab, setTab] = useState<"login" | "signup" | "external">("login");
|
|
||||||
const [localUsername, setLocalUsername] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [oidcLoading, setOidcLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [internalLoggedIn, setInternalLoggedIn] = useState(false);
|
|
||||||
const [firstUser, setFirstUser] = useState(false);
|
|
||||||
const [registrationAllowed, setRegistrationAllowed] = useState(true);
|
|
||||||
const [oidcConfigured, setOidcConfigured] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setInternalLoggedIn(loggedIn);
|
|
||||||
}, [loggedIn]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
API.get("/registration-allowed").then(res => {
|
|
||||||
setRegistrationAllowed(res.data.allowed);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
API.get("/oidc-config").then((response) => {
|
|
||||||
if (response.data) {
|
|
||||||
setOidcConfigured(true);
|
|
||||||
} else {
|
|
||||||
setOidcConfigured(false);
|
|
||||||
}
|
|
||||||
}).catch((error) => {
|
|
||||||
if (error.response?.status === 404) {
|
|
||||||
setOidcConfigured(false);
|
|
||||||
} else {
|
|
||||||
setOidcConfigured(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
API.get("/count").then(res => {
|
|
||||||
if (res.data.count === 0) {
|
|
||||||
setFirstUser(true);
|
|
||||||
setTab("signup");
|
|
||||||
} else {
|
|
||||||
setFirstUser(false);
|
|
||||||
}
|
|
||||||
setDbError(null);
|
|
||||||
}).catch(() => {
|
|
||||||
setDbError("Could not connect to the database. Please try again later.");
|
|
||||||
});
|
|
||||||
}, [setDbError]);
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
setError(null);
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
let res, meRes;
|
|
||||||
if (tab === "login") {
|
|
||||||
res = await API.post("/login", {username: localUsername, password});
|
|
||||||
} else {
|
|
||||||
await API.post("/create", {username: localUsername, password});
|
|
||||||
res = await API.post("/login", {username: localUsername, password});
|
|
||||||
}
|
|
||||||
setCookie("jwt", res.data.token);
|
|
||||||
[meRes] = await Promise.all([
|
|
||||||
API.get("/me", {headers: {Authorization: `Bearer ${res.data.token}`}}),
|
|
||||||
API.get("/db-health")
|
|
||||||
]);
|
|
||||||
setInternalLoggedIn(true);
|
|
||||||
setLoggedIn(true);
|
|
||||||
setIsAdmin(!!meRes.data.is_admin);
|
|
||||||
setUsername(meRes.data.username || null);
|
|
||||||
setDbError(null);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err?.response?.data?.error || "Unknown error");
|
|
||||||
setInternalLoggedIn(false);
|
|
||||||
setLoggedIn(false);
|
|
||||||
setIsAdmin(false);
|
|
||||||
setUsername(null);
|
|
||||||
setCookie("jwt", "", -1);
|
|
||||||
if (err?.response?.data?.error?.includes("Database")) {
|
|
||||||
setDbError("Could not connect to the database. Please try again later.");
|
|
||||||
} else {
|
|
||||||
setDbError(null);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleOIDCLogin() {
|
|
||||||
setError(null);
|
|
||||||
setOidcLoading(true);
|
|
||||||
try {
|
|
||||||
const authResponse = await API.get("/oidc/authorize");
|
|
||||||
const {auth_url: authUrl} = authResponse.data;
|
|
||||||
|
|
||||||
if (!authUrl || authUrl === 'undefined') {
|
|
||||||
throw new Error('Invalid authorization URL received from backend');
|
|
||||||
}
|
|
||||||
|
|
||||||
window.location.replace(authUrl);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err?.response?.data?.error || err?.message || "Failed to start OIDC login");
|
|
||||||
setOidcLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
const success = urlParams.get('success');
|
|
||||||
const token = urlParams.get('token');
|
|
||||||
const error = urlParams.get('error');
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
setError(`OIDC authentication failed: ${error}`);
|
|
||||||
setOidcLoading(false);
|
|
||||||
window.history.replaceState({}, document.title, window.location.pathname);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (success && token) {
|
|
||||||
setOidcLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
setCookie("jwt", token);
|
|
||||||
API.get("/me", {headers: {Authorization: `Bearer ${token}`}})
|
|
||||||
.then(meRes => {
|
|
||||||
setInternalLoggedIn(true);
|
|
||||||
setLoggedIn(true);
|
|
||||||
setIsAdmin(!!meRes.data.is_admin);
|
|
||||||
setUsername(meRes.data.username || null);
|
|
||||||
setDbError(null);
|
|
||||||
window.history.replaceState({}, document.title, window.location.pathname);
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
setError("Failed to get user info after OIDC login");
|
|
||||||
setInternalLoggedIn(false);
|
|
||||||
setLoggedIn(false);
|
|
||||||
setIsAdmin(false);
|
|
||||||
setUsername(null);
|
|
||||||
setCookie("jwt", "", -1);
|
|
||||||
window.history.replaceState({}, document.title, window.location.pathname);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setOidcLoading(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const Spinner = (
|
|
||||||
<svg className="animate-spin mr-2 h-4 w-4 text-white inline-block" viewBox="0 0 24 24">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`w-[420px] max-w-full bg-background rounded-xl shadow-lg p-6 flex flex-col ${internalLoggedIn ? '' : 'border border-border'}`}>
|
|
||||||
{dbError && (
|
|
||||||
<Alert variant="destructive" className="mb-4">
|
|
||||||
<AlertTitle>Error</AlertTitle>
|
|
||||||
<AlertDescription>{dbError}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{firstUser && !dbError && !internalLoggedIn && (
|
|
||||||
<Alert variant="default" className="mb-4">
|
|
||||||
<AlertTitle>First User</AlertTitle>
|
|
||||||
<AlertDescription className="inline">
|
|
||||||
You are the first user and will be made an admin. You can view admin settings in the sidebar
|
|
||||||
user dropdown. If you think this is a mistake, check the docker logs, or create a{" "}
|
|
||||||
<a
|
|
||||||
href="https://github.com/LukeGus/Termix/issues/new"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-blue-600 underline hover:text-blue-800 inline"
|
|
||||||
>
|
|
||||||
GitHub issue
|
|
||||||
</a>.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{!registrationAllowed && !internalLoggedIn && (
|
|
||||||
<Alert variant="destructive" className="mb-4">
|
|
||||||
<AlertTitle>Registration Disabled</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
New account registration is currently disabled by an admin. Please log in or contact an
|
|
||||||
administrator.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{(internalLoggedIn || (authLoading && getCookie("jwt"))) && (
|
|
||||||
<div className="flex flex-col items-center gap-4">
|
|
||||||
<Alert className="my-2">
|
|
||||||
<AlertTitle>Logged in!</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
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.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<div className="flex flex-row items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="link"
|
|
||||||
className="text-sm"
|
|
||||||
onClick={() => window.open('https://github.com/LukeGus/Termix', '_blank')}
|
|
||||||
>
|
|
||||||
GitHub
|
|
||||||
</Button>
|
|
||||||
<div className="w-px h-4 bg-border"></div>
|
|
||||||
<Button
|
|
||||||
variant="link"
|
|
||||||
className="text-sm"
|
|
||||||
onClick={() => window.open('https://github.com/LukeGus/Termix/issues/new', '_blank')}
|
|
||||||
>
|
|
||||||
Feedback
|
|
||||||
</Button>
|
|
||||||
<div className="w-px h-4 bg-border"></div>
|
|
||||||
<Button
|
|
||||||
variant="link"
|
|
||||||
className="text-sm"
|
|
||||||
onClick={() => window.open('https://discord.com/invite/jVQGdvHDrf', '_blank')}
|
|
||||||
>
|
|
||||||
Discord
|
|
||||||
</Button>
|
|
||||||
<div className="w-px h-4 bg-border"></div>
|
|
||||||
<Button
|
|
||||||
variant="link"
|
|
||||||
className="text-sm"
|
|
||||||
onClick={() => window.open('https://github.com/sponsors/LukeGus', '_blank')}
|
|
||||||
>
|
|
||||||
Fund
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(!internalLoggedIn && (!authLoading || !getCookie("jwt"))) && (
|
|
||||||
<>
|
|
||||||
<div className="flex gap-2 mb-6">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
|
||||||
tab === "login"
|
|
||||||
? "bg-primary text-primary-foreground shadow"
|
|
||||||
: "bg-muted text-muted-foreground hover:bg-accent"
|
|
||||||
)}
|
|
||||||
onClick={() => setTab("login")}
|
|
||||||
aria-selected={tab === "login"}
|
|
||||||
disabled={loading || firstUser}
|
|
||||||
>
|
|
||||||
Login
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
|
||||||
tab === "signup"
|
|
||||||
? "bg-primary text-primary-foreground shadow"
|
|
||||||
: "bg-muted text-muted-foreground hover:bg-accent"
|
|
||||||
)}
|
|
||||||
onClick={() => setTab("signup")}
|
|
||||||
aria-selected={tab === "signup"}
|
|
||||||
disabled={loading || !registrationAllowed}
|
|
||||||
>
|
|
||||||
Sign Up
|
|
||||||
</button>
|
|
||||||
{oidcConfigured && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
|
||||||
tab === "external"
|
|
||||||
? "bg-primary text-primary-foreground shadow"
|
|
||||||
: "bg-muted text-muted-foreground hover:bg-accent"
|
|
||||||
)}
|
|
||||||
onClick={() => setTab("external")}
|
|
||||||
aria-selected={tab === "external"}
|
|
||||||
disabled={oidcLoading}
|
|
||||||
>
|
|
||||||
External
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mb-6 text-center">
|
|
||||||
<h2 className="text-xl font-bold mb-1">
|
|
||||||
{tab === "login" ? "Login to your account" :
|
|
||||||
tab === "signup" ? "Create a new account" :
|
|
||||||
"Login with external provider"}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{tab === "external" ? (
|
|
||||||
<div className="flex flex-col gap-5">
|
|
||||||
<div className="text-center text-muted-foreground mb-4">
|
|
||||||
<p>Login using your configured external identity provider</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="w-full h-11 mt-2 text-base font-semibold"
|
|
||||||
disabled={oidcLoading}
|
|
||||||
onClick={handleOIDCLogin}
|
|
||||||
>
|
|
||||||
{oidcLoading ? Spinner : "Login with External Provider"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label htmlFor="username">Username</Label>
|
|
||||||
<Input
|
|
||||||
id="username"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
className="h-11 text-base"
|
|
||||||
value={localUsername}
|
|
||||||
onChange={e => setLocalUsername(e.target.value)}
|
|
||||||
disabled={loading || internalLoggedIn}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label htmlFor="password">Password</Label>
|
|
||||||
<Input id="password" type="password" required className="h-11 text-base"
|
|
||||||
value={password} onChange={e => setPassword(e.target.value)}
|
|
||||||
disabled={loading || internalLoggedIn}/>
|
|
||||||
</div>
|
|
||||||
<Button type="submit" className="w-full h-11 mt-2 text-base font-semibold"
|
|
||||||
disabled={loading || internalLoggedIn}>
|
|
||||||
{loading ? Spinner : (tab === "login" ? "Login" : "Sign Up")}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{error && (
|
|
||||||
<Alert variant="destructive" className="mt-4">
|
|
||||||
<AlertTitle>Error</AlertTitle>
|
|
||||||
<AlertDescription>{error}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,438 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import {
|
|
||||||
Computer,
|
|
||||||
Server,
|
|
||||||
File,
|
|
||||||
Hammer, ChevronUp, User2, HardDrive
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Sidebar,
|
|
||||||
SidebarContent, SidebarFooter,
|
|
||||||
SidebarGroup,
|
|
||||||
SidebarGroupContent,
|
|
||||||
SidebarGroupLabel,
|
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuButton,
|
|
||||||
SidebarMenuItem, SidebarProvider, SidebarInset,
|
|
||||||
} from "@/components/ui/sidebar.tsx"
|
|
||||||
|
|
||||||
import {
|
|
||||||
Separator,
|
|
||||||
} from "@/components/ui/separator.tsx"
|
|
||||||
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@radix-ui/react-dropdown-menu";
|
|
||||||
import {
|
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetDescription,
|
|
||||||
SheetFooter,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
SheetTrigger,
|
|
||||||
SheetClose
|
|
||||||
} from "@/components/ui/sheet";
|
|
||||||
import {Checkbox} from "@/components/ui/checkbox.tsx";
|
|
||||||
import {Input} from "@/components/ui/input.tsx";
|
|
||||||
import {Label} from "@/components/ui/label.tsx";
|
|
||||||
import {Button} from "@/components/ui/button.tsx";
|
|
||||||
import {Alert, AlertTitle, AlertDescription} from "@/components/ui/alert.tsx";
|
|
||||||
import axios from "axios";
|
|
||||||
|
|
||||||
interface SidebarProps {
|
|
||||||
onSelectView: (view: string) => void;
|
|
||||||
getView?: () => string;
|
|
||||||
disabled?: boolean;
|
|
||||||
isAdmin?: boolean;
|
|
||||||
username?: string | null;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleLogout() {
|
|
||||||
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCookie(name: string) {
|
|
||||||
return document.cookie.split('; ').reduce((r, v) => {
|
|
||||||
const parts = v.split('=');
|
|
||||||
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
|
|
||||||
}, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users";
|
|
||||||
|
|
||||||
const API = axios.create({
|
|
||||||
baseURL: apiBase,
|
|
||||||
});
|
|
||||||
|
|
||||||
export function HomepageSidebar({
|
|
||||||
onSelectView,
|
|
||||||
getView,
|
|
||||||
disabled,
|
|
||||||
isAdmin,
|
|
||||||
username,
|
|
||||||
children,
|
|
||||||
}: SidebarProps): React.ReactElement {
|
|
||||||
const [adminSheetOpen, setAdminSheetOpen] = React.useState(false);
|
|
||||||
const [allowRegistration, setAllowRegistration] = React.useState(true);
|
|
||||||
const [regLoading, setRegLoading] = React.useState(false);
|
|
||||||
const [oidcConfig, setOidcConfig] = React.useState({
|
|
||||||
client_id: '',
|
|
||||||
client_secret: '',
|
|
||||||
issuer_url: '',
|
|
||||||
authorization_url: '',
|
|
||||||
token_url: '',
|
|
||||||
identifier_path: 'sub',
|
|
||||||
name_path: 'name',
|
|
||||||
scopes: 'openid email profile'
|
|
||||||
});
|
|
||||||
const [oidcLoading, setOidcLoading] = React.useState(false);
|
|
||||||
const [oidcError, setOidcError] = React.useState<string | null>(null);
|
|
||||||
const [oidcSuccess, setOidcSuccess] = React.useState<string | null>(null);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (adminSheetOpen) {
|
|
||||||
API.get("/registration-allowed").then(res => {
|
|
||||||
setAllowRegistration(res.data.allowed);
|
|
||||||
});
|
|
||||||
|
|
||||||
API.get("/oidc-config").then(res => {
|
|
||||||
if (res.data) {
|
|
||||||
setOidcConfig(res.data);
|
|
||||||
}
|
|
||||||
}).catch((error) => {
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [adminSheetOpen]);
|
|
||||||
|
|
||||||
const handleToggle = async (checked: boolean) => {
|
|
||||||
setRegLoading(true);
|
|
||||||
const jwt = getCookie("jwt");
|
|
||||||
try {
|
|
||||||
await API.patch(
|
|
||||||
"/registration-allowed",
|
|
||||||
{allowed: checked},
|
|
||||||
{headers: {Authorization: `Bearer ${jwt}`}}
|
|
||||||
);
|
|
||||||
setAllowRegistration(checked);
|
|
||||||
} catch (e) {
|
|
||||||
} finally {
|
|
||||||
setRegLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOIDCConfigSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setOidcLoading(true);
|
|
||||||
setOidcError(null);
|
|
||||||
setOidcSuccess(null);
|
|
||||||
|
|
||||||
const requiredFields = ['client_id', 'client_secret', 'issuer_url', 'authorization_url', 'token_url'];
|
|
||||||
const missingFields = requiredFields.filter(field => !oidcConfig[field as keyof typeof oidcConfig]);
|
|
||||||
|
|
||||||
if (missingFields.length > 0) {
|
|
||||||
setOidcError(`Missing required fields: ${missingFields.join(', ')}`);
|
|
||||||
setOidcLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const jwt = getCookie("jwt");
|
|
||||||
try {
|
|
||||||
await API.post(
|
|
||||||
"/oidc-config",
|
|
||||||
oidcConfig,
|
|
||||||
{headers: {Authorization: `Bearer ${jwt}`}}
|
|
||||||
);
|
|
||||||
setOidcSuccess("OIDC configuration updated successfully!");
|
|
||||||
} catch (err: any) {
|
|
||||||
setOidcError(err?.response?.data?.error || "Failed to update OIDC configuration");
|
|
||||||
} finally {
|
|
||||||
setOidcLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOIDCConfigChange = (field: string, value: string) => {
|
|
||||||
setOidcConfig(prev => ({
|
|
||||||
...prev,
|
|
||||||
[field]: value
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-svh">
|
|
||||||
<SidebarProvider>
|
|
||||||
<Sidebar>
|
|
||||||
<SidebarContent>
|
|
||||||
<SidebarGroup>
|
|
||||||
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
|
|
||||||
Termix
|
|
||||||
</SidebarGroupLabel>
|
|
||||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
|
||||||
<SidebarGroupContent>
|
|
||||||
<SidebarMenu>
|
|
||||||
<SidebarMenuItem key={"SSH Manager"}>
|
|
||||||
<SidebarMenuButton onClick={() => onSelectView("ssh_manager")}
|
|
||||||
disabled={disabled}>
|
|
||||||
<HardDrive/>
|
|
||||||
<span>SSH Manager</span>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
<div className="ml-5">
|
|
||||||
<SidebarMenuItem key={"Terminal"}>
|
|
||||||
<SidebarMenuButton onClick={() => onSelectView("terminal")}
|
|
||||||
disabled={disabled}>
|
|
||||||
<Computer/>
|
|
||||||
<span>Terminal</span>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
<SidebarMenuItem key={"Tunnel"}>
|
|
||||||
<SidebarMenuButton onClick={() => onSelectView("tunnel")}
|
|
||||||
disabled={disabled}>
|
|
||||||
<Server/>
|
|
||||||
<span>Tunnel</span>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
<SidebarMenuItem key={"Config Editor"}>
|
|
||||||
<SidebarMenuButton onClick={() => onSelectView("config_editor")}
|
|
||||||
disabled={disabled}>
|
|
||||||
<File/>
|
|
||||||
<span>Config Editor</span>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
</div>
|
|
||||||
<SidebarMenuItem key={"Tools"}>
|
|
||||||
<SidebarMenuButton onClick={() => window.open("https://dashix.dev", "_blank")} disabled={disabled}>
|
|
||||||
<Hammer/>
|
|
||||||
<span>Tools</span>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroupContent>
|
|
||||||
</SidebarGroup>
|
|
||||||
</SidebarContent>
|
|
||||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
|
||||||
<SidebarFooter>
|
|
||||||
<SidebarMenu>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<SidebarMenuButton
|
|
||||||
className="data-[state=open]:opacity-90 w-full"
|
|
||||||
style={{width: '100%'}}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
<User2/> {username ? username : 'Signed out'}
|
|
||||||
<ChevronUp className="ml-auto"/>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
side="top"
|
|
||||||
align="start"
|
|
||||||
sideOffset={6}
|
|
||||||
className="min-w-[var(--radix-popper-anchor-width)] bg-sidebar-accent text-sidebar-accent-foreground border border-border rounded-md shadow-2xl p-1"
|
|
||||||
>
|
|
||||||
{isAdmin && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
|
|
||||||
onSelect={() => setAdminSheetOpen(true)}>
|
|
||||||
<span>Admin Settings</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
|
|
||||||
onSelect={handleLogout}>
|
|
||||||
<span>Sign out</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarFooter>
|
|
||||||
{/* Admin Settings Sheet (always rendered, only openable if isAdmin) */}
|
|
||||||
{isAdmin && (
|
|
||||||
<Sheet open={adminSheetOpen} onOpenChange={setAdminSheetOpen}>
|
|
||||||
<SheetContent side="left" className="w-[400px] max-h-screen overflow-y-auto">
|
|
||||||
<SheetHeader>
|
|
||||||
<SheetTitle>Admin Settings</SheetTitle>
|
|
||||||
</SheetHeader>
|
|
||||||
<div className="pt-1 pb-4 px-4 flex flex-col gap-6">
|
|
||||||
{/* Registration Settings */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-lg font-semibold">User Registration</h3>
|
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<Checkbox checked={allowRegistration} onCheckedChange={handleToggle}
|
|
||||||
disabled={regLoading}/>
|
|
||||||
Allow new account registration
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator className="p-0.25 mt-2 mb-2"/>
|
|
||||||
|
|
||||||
{/* OIDC Configuration */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-lg font-semibold">External Authentication (OIDC)</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Configure external identity provider for OIDC/OAuth2 authentication.
|
|
||||||
Users will see an "External" login option once configured.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{oidcError && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertTitle>Error</AlertTitle>
|
|
||||||
<AlertDescription>{oidcError}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="client_id">Client ID</Label>
|
|
||||||
<Input
|
|
||||||
id="client_id"
|
|
||||||
value={oidcConfig.client_id}
|
|
||||||
onChange={(e) => handleOIDCConfigChange('client_id', e.target.value)}
|
|
||||||
placeholder="your-client-id"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="client_secret">Client Secret</Label>
|
|
||||||
<Input
|
|
||||||
id="client_secret"
|
|
||||||
type="password"
|
|
||||||
value={oidcConfig.client_secret}
|
|
||||||
onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)}
|
|
||||||
placeholder="your-client-secret"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="authorization_url">Authorization URL</Label>
|
|
||||||
<Input
|
|
||||||
id="authorization_url"
|
|
||||||
value={oidcConfig.authorization_url}
|
|
||||||
onChange={(e) => handleOIDCConfigChange('authorization_url', e.target.value)}
|
|
||||||
placeholder="https://your-provider.com/application/o/authorize/"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="issuer_url">Issuer URL</Label>
|
|
||||||
<Input
|
|
||||||
id="issuer_url"
|
|
||||||
value={oidcConfig.issuer_url}
|
|
||||||
onChange={(e) => handleOIDCConfigChange('issuer_url', e.target.value)}
|
|
||||||
placeholder="https://your-provider.com/application/o/termix/"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="token_url">Token URL</Label>
|
|
||||||
<Input
|
|
||||||
id="token_url"
|
|
||||||
value={oidcConfig.token_url}
|
|
||||||
onChange={(e) => handleOIDCConfigChange('token_url', e.target.value)}
|
|
||||||
placeholder="http://100.98.3.50:9000/application/o/token/"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="identifier_path">User Identifier Path</Label>
|
|
||||||
<Input
|
|
||||||
id="identifier_path"
|
|
||||||
value={oidcConfig.identifier_path}
|
|
||||||
onChange={(e) => handleOIDCConfigChange('identifier_path', e.target.value)}
|
|
||||||
placeholder="sub"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
JSON path to extract user ID from JWT (e.g., "sub", "email", "preferred_username")
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="name_path">Display Name Path</Label>
|
|
||||||
<Input
|
|
||||||
id="name_path"
|
|
||||||
value={oidcConfig.name_path}
|
|
||||||
onChange={(e) => handleOIDCConfigChange('name_path', e.target.value)}
|
|
||||||
placeholder="name"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
JSON path to extract display name from JWT (e.g., "name", "preferred_username")
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="scopes">Scopes</Label>
|
|
||||||
<Input
|
|
||||||
id="scopes"
|
|
||||||
value={oidcConfig.scopes}
|
|
||||||
onChange={(e) => handleOIDCConfigChange('scopes', e.target.value)}
|
|
||||||
placeholder="openid email profile"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Space-separated list of OAuth2 scopes to request
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
className="flex-1"
|
|
||||||
disabled={oidcLoading}
|
|
||||||
>
|
|
||||||
{oidcLoading ? "Saving..." : "Save Configuration"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setOidcConfig({
|
|
||||||
client_id: '',
|
|
||||||
client_secret: '',
|
|
||||||
issuer_url: '',
|
|
||||||
authorization_url: '',
|
|
||||||
token_url: '',
|
|
||||||
identifier_path: 'sub',
|
|
||||||
name_path: 'name',
|
|
||||||
scopes: 'openid email profile'
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{oidcSuccess && (
|
|
||||||
<Alert>
|
|
||||||
<AlertTitle>Success</AlertTitle>
|
|
||||||
<AlertDescription>{oidcSuccess}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<SheetFooter className="px-4 pt-1 pb-4">
|
|
||||||
<Separator className="p-0.25 mt-2 mb-2"/>
|
|
||||||
<SheetClose asChild>
|
|
||||||
<Button variant="outline">Close</Button>
|
|
||||||
</SheetClose>
|
|
||||||
</SheetFooter>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
)}
|
|
||||||
</Sidebar>
|
|
||||||
<SidebarInset>
|
|
||||||
{children}
|
|
||||||
</SidebarInset>
|
|
||||||
</SidebarProvider>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,594 +0,0 @@
|
|||||||
import React, {useEffect, useState, useRef, forwardRef, useImperativeHandle} from 'react';
|
|
||||||
import {
|
|
||||||
Sidebar,
|
|
||||||
SidebarContent,
|
|
||||||
SidebarGroup,
|
|
||||||
SidebarGroupContent,
|
|
||||||
SidebarGroupLabel, SidebarMenu, SidebarMenuItem,
|
|
||||||
SidebarProvider
|
|
||||||
} from '@/components/ui/sidebar.tsx';
|
|
||||||
import {Separator} from '@/components/ui/separator.tsx';
|
|
||||||
import {CornerDownLeft, Folder, File, Server, ArrowUp, Pin} from 'lucide-react';
|
|
||||||
import {ScrollArea} from '@/components/ui/scroll-area.tsx';
|
|
||||||
import {cn} from '@/lib/utils.ts';
|
|
||||||
import {Input} from '@/components/ui/input.tsx';
|
|
||||||
import {Button} from '@/components/ui/button.tsx';
|
|
||||||
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from '@/components/ui/accordion.tsx';
|
|
||||||
import {
|
|
||||||
getSSHHosts,
|
|
||||||
listSSHFiles,
|
|
||||||
connectSSH,
|
|
||||||
getSSHStatus,
|
|
||||||
getConfigEditorPinned,
|
|
||||||
addConfigEditorPinned,
|
|
||||||
removeConfigEditorPinned
|
|
||||||
} from '@/apps/SSH/ssh-axios.ts';
|
|
||||||
|
|
||||||
interface SSHHost {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
ip: string;
|
|
||||||
port: number;
|
|
||||||
username: string;
|
|
||||||
folder: string;
|
|
||||||
tags: string[];
|
|
||||||
pin: boolean;
|
|
||||||
authType: string;
|
|
||||||
password?: string;
|
|
||||||
key?: string;
|
|
||||||
keyPassword?: string;
|
|
||||||
keyType?: string;
|
|
||||||
enableTerminal: boolean;
|
|
||||||
enableTunnel: boolean;
|
|
||||||
enableConfigEditor: boolean;
|
|
||||||
defaultPath: string;
|
|
||||||
tunnelConnections: any[];
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
|
|
||||||
{onSelectView, onOpenFile, tabs, onHostChange}: {
|
|
||||||
onSelectView: (view: string) => void;
|
|
||||||
onOpenFile: (file: any) => void;
|
|
||||||
tabs: any[];
|
|
||||||
onHostChange?: (host: SSHHost | null) => void;
|
|
||||||
},
|
|
||||||
ref
|
|
||||||
) {
|
|
||||||
const [sshConnections, setSSHConnections] = useState<SSHHost[]>([]);
|
|
||||||
const [loadingSSH, setLoadingSSH] = useState(false);
|
|
||||||
const [errorSSH, setErrorSSH] = useState<string | undefined>(undefined);
|
|
||||||
const [view, setView] = useState<'servers' | 'files'>('servers');
|
|
||||||
const [activeServer, setActiveServer] = useState<SSHHost | null>(null);
|
|
||||||
const [currentPath, setCurrentPath] = useState('/');
|
|
||||||
const [files, setFiles] = useState<any[]>([]);
|
|
||||||
const pathInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
|
||||||
const [fileSearch, setFileSearch] = useState('');
|
|
||||||
const [debouncedFileSearch, setDebouncedFileSearch] = useState('');
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = setTimeout(() => setDebouncedSearch(search), 200);
|
|
||||||
return () => clearTimeout(handler);
|
|
||||||
}, [search]);
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = setTimeout(() => setDebouncedFileSearch(fileSearch), 200);
|
|
||||||
return () => clearTimeout(handler);
|
|
||||||
}, [fileSearch]);
|
|
||||||
|
|
||||||
const [sshSessionId, setSshSessionId] = useState<string | null>(null);
|
|
||||||
const [filesLoading, setFilesLoading] = useState(false);
|
|
||||||
const [filesError, setFilesError] = useState<string | null>(null);
|
|
||||||
const [connectingSSH, setConnectingSSH] = useState(false);
|
|
||||||
const [connectionCache, setConnectionCache] = useState<Record<string, {
|
|
||||||
sessionId: string;
|
|
||||||
timestamp: number
|
|
||||||
}>>({});
|
|
||||||
const [fetchingFiles, setFetchingFiles] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchSSH();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function fetchSSH() {
|
|
||||||
setLoadingSSH(true);
|
|
||||||
setErrorSSH(undefined);
|
|
||||||
try {
|
|
||||||
const hosts = await getSSHHosts();
|
|
||||||
const configEditorHosts = hosts.filter(host => host.enableConfigEditor);
|
|
||||||
|
|
||||||
if (configEditorHosts.length > 0) {
|
|
||||||
const firstHost = configEditorHosts[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
setSSHConnections(configEditorHosts);
|
|
||||||
} catch (err: any) {
|
|
||||||
setErrorSSH('Failed to load SSH connections');
|
|
||||||
} finally {
|
|
||||||
setLoadingSSH(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function connectToSSH(server: SSHHost): Promise<string | null> {
|
|
||||||
const sessionId = server.id.toString();
|
|
||||||
|
|
||||||
const cached = connectionCache[sessionId];
|
|
||||||
if (cached && Date.now() - cached.timestamp < 30000) {
|
|
||||||
setSshSessionId(cached.sessionId);
|
|
||||||
return cached.sessionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (connectingSSH) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
setConnectingSSH(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!server.password && !server.key) {
|
|
||||||
setFilesError('No authentication credentials available for this SSH host');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const connectionConfig = {
|
|
||||||
ip: server.ip,
|
|
||||||
port: server.port,
|
|
||||||
username: server.username,
|
|
||||||
password: server.password,
|
|
||||||
sshKey: server.key,
|
|
||||||
keyPassword: server.keyPassword,
|
|
||||||
};
|
|
||||||
|
|
||||||
await connectSSH(sessionId, connectionConfig);
|
|
||||||
|
|
||||||
setSshSessionId(sessionId);
|
|
||||||
|
|
||||||
setConnectionCache(prev => ({
|
|
||||||
...prev,
|
|
||||||
[sessionId]: {sessionId, timestamp: Date.now()}
|
|
||||||
}));
|
|
||||||
|
|
||||||
return sessionId;
|
|
||||||
} catch (err: any) {
|
|
||||||
setFilesError(err?.response?.data?.error || 'Failed to connect to SSH');
|
|
||||||
setSshSessionId(null);
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
setConnectingSSH(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchFiles() {
|
|
||||||
if (fetchingFiles) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setFetchingFiles(true);
|
|
||||||
setFiles([]);
|
|
||||||
setFilesLoading(true);
|
|
||||||
setFilesError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
let pinnedFiles: any[] = [];
|
|
||||||
try {
|
|
||||||
if (activeServer) {
|
|
||||||
pinnedFiles = await getConfigEditorPinned(activeServer.id);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeServer && sshSessionId) {
|
|
||||||
let res: any[] = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const status = await getSSHStatus(sshSessionId);
|
|
||||||
if (!status.connected) {
|
|
||||||
const newSessionId = await connectToSSH(activeServer);
|
|
||||||
if (newSessionId) {
|
|
||||||
setSshSessionId(newSessionId);
|
|
||||||
res = await listSSHFiles(newSessionId, currentPath);
|
|
||||||
} else {
|
|
||||||
throw new Error('Failed to reconnect SSH session');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
res = await listSSHFiles(sshSessionId, currentPath);
|
|
||||||
}
|
|
||||||
} catch (sessionErr) {
|
|
||||||
const newSessionId = await connectToSSH(activeServer);
|
|
||||||
if (newSessionId) {
|
|
||||||
setSshSessionId(newSessionId);
|
|
||||||
res = await listSSHFiles(newSessionId, currentPath);
|
|
||||||
} else {
|
|
||||||
throw sessionErr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const processedFiles = (res || []).map((f: any) => {
|
|
||||||
const filePath = currentPath + (currentPath.endsWith('/') ? '' : '/') + f.name;
|
|
||||||
const isPinned = pinnedFiles.some(pinned => pinned.path === filePath);
|
|
||||||
return {
|
|
||||||
...f,
|
|
||||||
path: filePath,
|
|
||||||
isPinned,
|
|
||||||
isSSH: true,
|
|
||||||
sshSessionId: sshSessionId
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
setFiles(processedFiles);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
setFiles([]);
|
|
||||||
setFilesError(err?.response?.data?.error || err?.message || 'Failed to list files');
|
|
||||||
} finally {
|
|
||||||
setFilesLoading(false);
|
|
||||||
setFetchingFiles(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (view === 'files' && activeServer && sshSessionId && !connectingSSH && !fetchingFiles) {
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
fetchFiles();
|
|
||||||
}, 100);
|
|
||||||
return () => clearTimeout(timeoutId);
|
|
||||||
}
|
|
||||||
}, [currentPath, view, activeServer, sshSessionId]);
|
|
||||||
|
|
||||||
async function handleSelectServer(server: SSHHost) {
|
|
||||||
if (connectingSSH) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setFetchingFiles(false);
|
|
||||||
setFilesLoading(false);
|
|
||||||
setFilesError(null);
|
|
||||||
setFiles([]);
|
|
||||||
|
|
||||||
setActiveServer(server);
|
|
||||||
setCurrentPath(server.defaultPath || '/');
|
|
||||||
setView('files');
|
|
||||||
|
|
||||||
const sessionId = await connectToSSH(server);
|
|
||||||
if (sessionId) {
|
|
||||||
setSshSessionId(sessionId);
|
|
||||||
if (onHostChange) {
|
|
||||||
onHostChange(server);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
w
|
|
||||||
setView('servers');
|
|
||||||
setActiveServer(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
|
||||||
openFolder: async (server: SSHHost, path: string) => {
|
|
||||||
if (connectingSSH || fetchingFiles) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeServer?.id === server.id && currentPath === path) {
|
|
||||||
setTimeout(() => fetchFiles(), 100);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setFetchingFiles(false);
|
|
||||||
setFilesLoading(false);
|
|
||||||
setFilesError(null);
|
|
||||||
setFiles([]);
|
|
||||||
|
|
||||||
setActiveServer(server);
|
|
||||||
setCurrentPath(path);
|
|
||||||
setView('files');
|
|
||||||
|
|
||||||
if (!sshSessionId || activeServer?.id !== server.id) {
|
|
||||||
const sessionId = await connectToSSH(server);
|
|
||||||
if (sessionId) {
|
|
||||||
setSshSessionId(sessionId);
|
|
||||||
if (onHostChange && activeServer?.id !== server.id) {
|
|
||||||
onHostChange(server);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setView('servers');
|
|
||||||
setActiveServer(null);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (onHostChange && activeServer?.id !== server.id) {
|
|
||||||
onHostChange(server);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
fetchFiles: () => {
|
|
||||||
if (activeServer && sshSessionId) {
|
|
||||||
fetchFiles();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (pathInputRef.current) {
|
|
||||||
pathInputRef.current.scrollLeft = pathInputRef.current.scrollWidth;
|
|
||||||
}
|
|
||||||
}, [currentPath]);
|
|
||||||
|
|
||||||
const sshByFolder: Record<string, SSHHost[]> = {};
|
|
||||||
sshConnections.forEach(conn => {
|
|
||||||
const folder = conn.folder && conn.folder.trim() ? conn.folder : 'No Folder';
|
|
||||||
if (!sshByFolder[folder]) sshByFolder[folder] = [];
|
|
||||||
sshByFolder[folder].push(conn);
|
|
||||||
});
|
|
||||||
|
|
||||||
const sortedFolders = Object.keys(sshByFolder);
|
|
||||||
if (sortedFolders.includes('No Folder')) {
|
|
||||||
sortedFolders.splice(sortedFolders.indexOf('No Folder'), 1);
|
|
||||||
sortedFolders.unshift('No Folder');
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredSshByFolder: Record<string, SSHHost[]> = {};
|
|
||||||
Object.entries(sshByFolder).forEach(([folder, hosts]) => {
|
|
||||||
filteredSshByFolder[folder] = hosts.filter(conn => {
|
|
||||||
const q = debouncedSearch.trim().toLowerCase();
|
|
||||||
if (!q) return true;
|
|
||||||
return (conn.name || '').toLowerCase().includes(q) || (conn.ip || '').toLowerCase().includes(q) ||
|
|
||||||
(conn.username || '').toLowerCase().includes(q) || (conn.folder || '').toLowerCase().includes(q) ||
|
|
||||||
(conn.tags || []).join(' ').toLowerCase().includes(q);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const filteredFiles = files.filter(file => {
|
|
||||||
const q = debouncedFileSearch.trim().toLowerCase();
|
|
||||||
if (!q) return true;
|
|
||||||
return file.name.toLowerCase().includes(q);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarProvider>
|
|
||||||
<Sidebar style={{height: '100vh', maxHeight: '100vh', overflow: 'hidden'}}>
|
|
||||||
<SidebarContent style={{height: '100vh', maxHeight: '100vh', overflow: 'hidden'}}>
|
|
||||||
<SidebarGroup className="flex flex-col flex-grow h-full overflow-hidden">
|
|
||||||
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
|
|
||||||
Termix / Config
|
|
||||||
</SidebarGroupLabel>
|
|
||||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
|
||||||
<SidebarGroupContent className="flex flex-col flex-grow min-h-0">
|
|
||||||
<SidebarMenu>
|
|
||||||
<SidebarMenuItem key={"Homepage"}>
|
|
||||||
<Button className="w-full mt-2 mb-2 h-8" onClick={() => onSelectView("homepage")}
|
|
||||||
variant="outline">
|
|
||||||
<CornerDownLeft/>
|
|
||||||
Return
|
|
||||||
</Button>
|
|
||||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
</SidebarMenu>
|
|
||||||
<div
|
|
||||||
className="flex-1 w-full flex flex-col rounded-md bg-[#09090b] border border-[#434345] overflow-hidden p-0 relative min-h-0 mt-1">
|
|
||||||
{view === 'servers' && (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className="w-full px-2 pt-2 pb-2 bg-[#09090b] z-10 border-b border-[#23232a]">
|
|
||||||
<Input
|
|
||||||
value={search}
|
|
||||||
onChange={e => setSearch(e.target.value)}
|
|
||||||
placeholder="Search hosts by name, username, IP, folder, tags..."
|
|
||||||
className="w-full h-8 text-sm bg-[#18181b] border border-[#23232a] text-white placeholder:text-muted-foreground rounded"
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<ScrollArea className="flex-1 w-full h-full"
|
|
||||||
style={{height: '100%', maxHeight: '100%'}}>
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<div
|
|
||||||
className="w-full flex-grow overflow-hidden p-0 m-0 relative flex flex-col min-h-0">
|
|
||||||
<div style={{display: 'flex', justifyContent: 'center'}}>
|
|
||||||
<Separator className="w-full h-px bg-[#434345] my-2"
|
|
||||||
style={{maxWidth: 213, margin: '0 auto'}}/>
|
|
||||||
</div>
|
|
||||||
<div className="mx-auto" style={{maxWidth: '213px', width: '100%'}}>
|
|
||||||
<div className="flex-1 min-h-0">
|
|
||||||
<Accordion type="multiple" className="w-full"
|
|
||||||
value={sortedFolders}>
|
|
||||||
{sortedFolders.map((folder, idx) => (
|
|
||||||
<React.Fragment key={folder}>
|
|
||||||
<AccordionItem value={folder}
|
|
||||||
className="mt-0 w-full !border-b-transparent">
|
|
||||||
<AccordionTrigger
|
|
||||||
className="text-base font-semibold rounded-t-none py-2 w-full">{folder}</AccordionTrigger>
|
|
||||||
<AccordionContent
|
|
||||||
className="flex flex-col gap-1 pb-2 pt-1 w-full">
|
|
||||||
{filteredSshByFolder[folder].map(conn => (
|
|
||||||
<Button
|
|
||||||
key={conn.id}
|
|
||||||
variant="outline"
|
|
||||||
className="w-full h-10 px-2 bg-[#18181b] border border-[#434345] hover:bg-[#2d2d30] transition-colors text-left justify-start"
|
|
||||||
onClick={() => handleSelectServer(conn)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="flex items-center w-full">
|
|
||||||
{conn.pin && <Pin
|
|
||||||
className="w-0.5 h-0.5 text-yellow-400 mr-1 flex-shrink-0"/>}
|
|
||||||
<span
|
|
||||||
className="font-medium truncate">{conn.name || conn.ip}</span>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
{idx < sortedFolders.length - 1 && (
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center'
|
|
||||||
}}>
|
|
||||||
<Separator
|
|
||||||
className="h-px bg-[#434345] my-1"
|
|
||||||
style={{width: 213}}/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</Accordion>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{view === 'files' && activeServer && (
|
|
||||||
<div className="flex flex-col h-full w-full" style={{maxWidth: 260}}>
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-2 px-2 py-2 border-b border-[#23232a] bg-[#18181b] z-20"
|
|
||||||
style={{maxWidth: 260}}>
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="outline"
|
|
||||||
className="h-8 w-8 bg-[#18181b] border border-[#23232a] rounded-md hover:bg-[#2d2d30] focus:outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
onClick={() => {
|
|
||||||
let path = currentPath;
|
|
||||||
if (path && path !== '/' && path !== '') {
|
|
||||||
if (path.endsWith('/')) path = path.slice(0, -1);
|
|
||||||
const lastSlash = path.lastIndexOf('/');
|
|
||||||
if (lastSlash > 0) {
|
|
||||||
setCurrentPath(path.slice(0, lastSlash));
|
|
||||||
} else {
|
|
||||||
setCurrentPath('/');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setView('servers');
|
|
||||||
if (onHostChange) {
|
|
||||||
onHostChange(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ArrowUp className="w-4 h-4"/>
|
|
||||||
</Button>
|
|
||||||
<Input ref={pathInputRef} value={currentPath}
|
|
||||||
onChange={e => setCurrentPath(e.target.value)}
|
|
||||||
className="flex-1 bg-[#18181b] border border-[#434345] text-white truncate rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring hover:border-[#5a5a5d]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="px-2 py-2 border-b border-[#23232a] bg-[#18181b]">
|
|
||||||
<Input
|
|
||||||
placeholder="Search files and folders..."
|
|
||||||
className="w-full h-7 text-sm bg-[#23232a] border border-[#434345] text-white placeholder:text-muted-foreground rounded"
|
|
||||||
autoComplete="off"
|
|
||||||
value={fileSearch}
|
|
||||||
onChange={e => setFileSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 w-full h-full bg-[#09090b] border-t border-[#23232a]">
|
|
||||||
<ScrollArea className="w-full h-full bg-[#09090b]" style={{
|
|
||||||
height: '100%',
|
|
||||||
maxHeight: '100%',
|
|
||||||
paddingRight: 8,
|
|
||||||
scrollbarGutter: 'stable',
|
|
||||||
background: '#09090b'
|
|
||||||
}}>
|
|
||||||
<div className="p-2 pr-2">
|
|
||||||
{connectingSSH || filesLoading ? (
|
|
||||||
<div className="text-xs text-muted-foreground">Loading...</div>
|
|
||||||
) : filesError ? (
|
|
||||||
<div className="text-xs text-red-500">{filesError}</div>
|
|
||||||
) : filteredFiles.length === 0 ? (
|
|
||||||
<div className="text-xs text-muted-foreground">No files or
|
|
||||||
folders found.</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{filteredFiles.map((item: any) => {
|
|
||||||
const isOpen = (tabs || []).some((t: any) => t.id === item.path);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={item.path}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 px-3 py-2 bg-[#18181b] border border-[#23232a] rounded group max-w-full",
|
|
||||||
isOpen && "opacity-60 cursor-not-allowed pointer-events-none"
|
|
||||||
)}
|
|
||||||
style={{maxWidth: 220, marginBottom: 8}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
|
|
||||||
onClick={() => !isOpen && (item.type === 'directory' ? setCurrentPath(item.path) : onOpenFile({
|
|
||||||
name: item.name,
|
|
||||||
path: item.path,
|
|
||||||
isSSH: item.isSSH,
|
|
||||||
sshSessionId: item.sshSessionId
|
|
||||||
}))}
|
|
||||||
>
|
|
||||||
{item.type === 'directory' ?
|
|
||||||
<Folder
|
|
||||||
className="w-4 h-4 text-blue-400"/> :
|
|
||||||
<File
|
|
||||||
className="w-4 h-4 text-muted-foreground"/>}
|
|
||||||
<span
|
|
||||||
className="text-sm text-white truncate max-w-[120px]">{item.name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{item.type === 'file' && (
|
|
||||||
<Button size="icon" variant="ghost"
|
|
||||||
className="h-7 w-7"
|
|
||||||
disabled={isOpen}
|
|
||||||
onClick={async (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
try {
|
|
||||||
if (item.isPinned) {
|
|
||||||
await removeConfigEditorPinned({
|
|
||||||
name: item.name,
|
|
||||||
path: item.path,
|
|
||||||
hostId: activeServer?.id,
|
|
||||||
isSSH: true,
|
|
||||||
sshSessionId: activeServer?.id.toString()
|
|
||||||
});
|
|
||||||
setFiles(files.map(f =>
|
|
||||||
f.path === item.path ? {
|
|
||||||
...f,
|
|
||||||
isPinned: false
|
|
||||||
} : f
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
await addConfigEditorPinned({
|
|
||||||
name: item.name,
|
|
||||||
path: item.path,
|
|
||||||
hostId: activeServer?.id,
|
|
||||||
isSSH: true,
|
|
||||||
sshSessionId: activeServer?.id.toString()
|
|
||||||
});
|
|
||||||
setFiles(files.map(f =>
|
|
||||||
f.path === item.path ? {
|
|
||||||
...f,
|
|
||||||
isPinned: true
|
|
||||||
} : f
|
|
||||||
));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to pin/unpin file:', err);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pin
|
|
||||||
className={`w-1 h-1 ${item.isPinned ? 'text-yellow-400 fill-current' : 'text-muted-foreground'}`}/>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SidebarGroupContent>
|
|
||||||
</SidebarGroup>
|
|
||||||
</SidebarContent>
|
|
||||||
</Sidebar>
|
|
||||||
</SidebarProvider>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
export {ConfigEditorSidebar};
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import {Button} from '@/components/ui/button.tsx';
|
|
||||||
import {X, Home} from 'lucide-react';
|
|
||||||
|
|
||||||
interface ConfigTab {
|
|
||||||
id: string | number;
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ConfigTabListProps {
|
|
||||||
tabs: ConfigTab[];
|
|
||||||
activeTab: string | number;
|
|
||||||
setActiveTab: (tab: string | number) => void;
|
|
||||||
closeTab: (tab: string | number) => void;
|
|
||||||
onHomeClick: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ConfigTabList({tabs, activeTab, setActiveTab, closeTab, onHomeClick}: ConfigTabListProps) {
|
|
||||||
return (
|
|
||||||
<div className="inline-flex items-center h-full px-[0.5rem] overflow-x-auto">
|
|
||||||
<Button
|
|
||||||
onClick={onHomeClick}
|
|
||||||
variant="outline"
|
|
||||||
className={`h-7 mr-[0.5rem] rounded-md flex items-center ${activeTab === 'home' ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
|
|
||||||
>
|
|
||||||
<Home className="w-4 h-4"/>
|
|
||||||
</Button>
|
|
||||||
{tabs.map((tab, index) => {
|
|
||||||
const isActive = tab.id === activeTab;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={tab.id}
|
|
||||||
className={index < tabs.length - 1 ? "mr-[0.5rem]" : ""}
|
|
||||||
>
|
|
||||||
<div className="inline-flex rounded-md shadow-sm" role="group">
|
|
||||||
<Button
|
|
||||||
onClick={() => setActiveTab(tab.id)}
|
|
||||||
variant="outline"
|
|
||||||
className={`h-7 rounded-r-none ${isActive ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
|
|
||||||
>
|
|
||||||
{tab.title}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={() => closeTab(tab.id)}
|
|
||||||
variant="outline"
|
|
||||||
className="h-7 rounded-l-none p-0 !w-9"
|
|
||||||
>
|
|
||||||
<X className="!w-5 !h-5" strokeWidth={2.5}/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { ConfigTabList } from "./ConfigTabList.tsx";
|
|
||||||
|
|
||||||
export function ConfigTopbar(props: any): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<ConfigTabList {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,312 +0,0 @@
|
|||||||
import React, {useState, useEffect, useMemo} from "react";
|
|
||||||
import {Card, CardContent} from "@/components/ui/card";
|
|
||||||
import {Button} from "@/components/ui/button";
|
|
||||||
import {Badge} from "@/components/ui/badge";
|
|
||||||
import {ScrollArea} from "@/components/ui/scroll-area";
|
|
||||||
import {Input} from "@/components/ui/input";
|
|
||||||
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion";
|
|
||||||
import {getSSHHosts, deleteSSHHost} from "@/apps/SSH/ssh-axios";
|
|
||||||
import {Edit, Trash2, Server, Folder, Tag, Pin, Terminal, Network, FileEdit, Search} from "lucide-react";
|
|
||||||
|
|
||||||
interface SSHHost {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
ip: string;
|
|
||||||
port: number;
|
|
||||||
username: string;
|
|
||||||
folder: string;
|
|
||||||
tags: string[];
|
|
||||||
pin: boolean;
|
|
||||||
authType: string;
|
|
||||||
enableTerminal: boolean;
|
|
||||||
enableTunnel: boolean;
|
|
||||||
enableConfigEditor: boolean;
|
|
||||||
defaultPath: string;
|
|
||||||
tunnelConnections: any[];
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SSHManagerHostViewerProps {
|
|
||||||
onEditHost?: (host: SSHHost) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SSHManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
|
||||||
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchHosts();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchHosts = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const data = await getSSHHosts();
|
|
||||||
setHosts(data);
|
|
||||||
setError(null);
|
|
||||||
} catch (err) {
|
|
||||||
setError('Failed to load hosts');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (hostId: number, hostName: string) => {
|
|
||||||
if (window.confirm(`Are you sure you want to delete "${hostName}"?`)) {
|
|
||||||
try {
|
|
||||||
await deleteSSHHost(hostId);
|
|
||||||
await fetchHosts();
|
|
||||||
} catch (err) {
|
|
||||||
alert('Failed to delete host');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEdit = (host: SSHHost) => {
|
|
||||||
if (onEditHost) {
|
|
||||||
onEditHost(host);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredAndSortedHosts = useMemo(() => {
|
|
||||||
let filtered = hosts;
|
|
||||||
|
|
||||||
if (searchQuery.trim()) {
|
|
||||||
const query = searchQuery.toLowerCase();
|
|
||||||
filtered = hosts.filter(host => {
|
|
||||||
const searchableText = [
|
|
||||||
host.name || '',
|
|
||||||
host.username,
|
|
||||||
host.ip,
|
|
||||||
host.folder || '',
|
|
||||||
...(host.tags || []),
|
|
||||||
host.authType,
|
|
||||||
host.defaultPath || ''
|
|
||||||
].join(' ').toLowerCase();
|
|
||||||
return searchableText.includes(query);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return filtered.sort((a, b) => {
|
|
||||||
if (a.pin && !b.pin) return -1;
|
|
||||||
if (!a.pin && b.pin) return 1;
|
|
||||||
|
|
||||||
const aName = a.name || a.username;
|
|
||||||
const bName = b.name || b.username;
|
|
||||||
return aName.localeCompare(bName);
|
|
||||||
});
|
|
||||||
}, [hosts, searchQuery]);
|
|
||||||
|
|
||||||
const hostsByFolder = useMemo(() => {
|
|
||||||
const grouped: { [key: string]: SSHHost[] } = {};
|
|
||||||
|
|
||||||
filteredAndSortedHosts.forEach(host => {
|
|
||||||
const folder = host.folder || 'Uncategorized';
|
|
||||||
if (!grouped[folder]) {
|
|
||||||
grouped[folder] = [];
|
|
||||||
}
|
|
||||||
grouped[folder].push(host);
|
|
||||||
});
|
|
||||||
|
|
||||||
const sortedFolders = Object.keys(grouped).sort((a, b) => {
|
|
||||||
if (a === 'Uncategorized') return -1;
|
|
||||||
if (b === 'Uncategorized') return 1;
|
|
||||||
return a.localeCompare(b);
|
|
||||||
});
|
|
||||||
|
|
||||||
const sortedGrouped: { [key: string]: SSHHost[] } = {};
|
|
||||||
sortedFolders.forEach(folder => {
|
|
||||||
sortedGrouped[folder] = grouped[folder];
|
|
||||||
});
|
|
||||||
|
|
||||||
return sortedGrouped;
|
|
||||||
}, [filteredAndSortedHosts]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-full">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
|
||||||
<p className="text-muted-foreground">Loading hosts...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-full">
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-red-500 mb-4">{error}</p>
|
|
||||||
<Button onClick={fetchHosts} variant="outline">
|
|
||||||
Retry
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hosts.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-full">
|
|
||||||
<div className="text-center">
|
|
||||||
<Server className="h-12 w-12 text-muted-foreground mx-auto mb-4"/>
|
|
||||||
<h3 className="text-lg font-semibold mb-2">No SSH Hosts</h3>
|
|
||||||
<p className="text-muted-foreground mb-4">
|
|
||||||
You haven't added any SSH hosts yet. Click "Add Host" to get started.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full min-h-0">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-semibold">SSH Hosts</h2>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
{filteredAndSortedHosts.length} hosts
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button onClick={fetchHosts} variant="outline" size="sm">
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative mb-3">
|
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
|
|
||||||
<Input
|
|
||||||
placeholder="Search hosts by name, username, IP, folder, tags..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ScrollArea className="flex-1 min-h-0">
|
|
||||||
<div className="space-y-2 pb-20">
|
|
||||||
{Object.entries(hostsByFolder).map(([folder, folderHosts]) => (
|
|
||||||
<div key={folder} className="border rounded-md">
|
|
||||||
<Accordion type="multiple" defaultValue={Object.keys(hostsByFolder)}>
|
|
||||||
<AccordionItem value={folder} className="border-none">
|
|
||||||
<AccordionTrigger
|
|
||||||
className="px-2 py-1 bg-muted/20 border-b hover:no-underline rounded-t-md">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Folder className="h-4 w-4"/>
|
|
||||||
<span className="font-medium">{folder}</span>
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
{folderHosts.length}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</AccordionTrigger>
|
|
||||||
<AccordionContent className="p-2">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
|
||||||
{folderHosts.map((host) => (
|
|
||||||
<div
|
|
||||||
key={host.id}
|
|
||||||
className="bg-[#222225] border border-input rounded cursor-pointer hover:shadow-md transition-shadow p-2"
|
|
||||||
onClick={() => handleEdit(host)}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{host.pin && <Pin
|
|
||||||
className="h-3 w-3 text-yellow-500 flex-shrink-0"/>}
|
|
||||||
<h3 className="font-medium truncate text-sm">
|
|
||||||
{host.name || `${host.username}@${host.ip}`}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
|
||||||
{host.ip}:{host.port}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
|
||||||
{host.username}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-1 flex-shrink-0 ml-1">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleEdit(host);
|
|
||||||
}}
|
|
||||||
className="h-5 w-5 p-0"
|
|
||||||
>
|
|
||||||
<Edit className="h-3 w-3"/>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleDelete(host.id, host.name || `${host.username}@${host.ip}`);
|
|
||||||
}}
|
|
||||||
className="h-5 w-5 p-0 text-red-500 hover:text-red-700"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3"/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-2 space-y-1">
|
|
||||||
{host.tags && host.tags.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{host.tags.slice(0, 6).map((tag, index) => (
|
|
||||||
<Badge key={index} variant="secondary"
|
|
||||||
className="text-xs px-1 py-0">
|
|
||||||
<Tag className="h-2 w-2 mr-0.5"/>
|
|
||||||
{tag}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
{host.tags.length > 6 && (
|
|
||||||
<Badge variant="outline"
|
|
||||||
className="text-xs px-1 py-0">
|
|
||||||
+{host.tags.length - 6}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{host.enableTerminal && (
|
|
||||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
|
||||||
<Terminal className="h-2 w-2 mr-0.5"/>
|
|
||||||
Terminal
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{host.enableTunnel && (
|
|
||||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
|
||||||
<Network className="h-2 w-2 mr-0.5"/>
|
|
||||||
Tunnel
|
|
||||||
{host.tunnelConnections && host.tunnelConnections.length > 0 && (
|
|
||||||
<span
|
|
||||||
className="ml-0.5">({host.tunnelConnections.length})</span>
|
|
||||||
)}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{host.enableConfigEditor && (
|
|
||||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
|
||||||
<FileEdit className="h-2 w-2 mr-0.5"/>
|
|
||||||
Config
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
</Accordion>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import {
|
|
||||||
CornerDownLeft
|
|
||||||
} from "lucide-react"
|
|
||||||
|
|
||||||
import {
|
|
||||||
Button
|
|
||||||
} from "@/components/ui/button.tsx"
|
|
||||||
|
|
||||||
import {
|
|
||||||
Sidebar,
|
|
||||||
SidebarContent,
|
|
||||||
SidebarGroup,
|
|
||||||
SidebarGroupContent,
|
|
||||||
SidebarGroupLabel,
|
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuItem, SidebarProvider,
|
|
||||||
} from "@/components/ui/sidebar.tsx"
|
|
||||||
|
|
||||||
import {
|
|
||||||
Separator,
|
|
||||||
} from "@/components/ui/separator.tsx"
|
|
||||||
|
|
||||||
interface SidebarProps {
|
|
||||||
onSelectView: (view: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SSHManagerSidebar({onSelectView}: SidebarProps): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<SidebarProvider>
|
|
||||||
<Sidebar>
|
|
||||||
<SidebarContent>
|
|
||||||
<SidebarGroup>
|
|
||||||
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
|
|
||||||
Termix / SSH Manager
|
|
||||||
</SidebarGroupLabel>
|
|
||||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
|
||||||
<SidebarGroupContent className="flex flex-col flex-grow">
|
|
||||||
<SidebarMenu>
|
|
||||||
|
|
||||||
{/* Sidebar Items */}
|
|
||||||
<SidebarMenuItem key={"Homepage"}>
|
|
||||||
<Button className="w-full mt-2 mb-2 h-8" onClick={() => onSelectView("homepage")}
|
|
||||||
variant="outline">
|
|
||||||
<CornerDownLeft/>
|
|
||||||
Return
|
|
||||||
</Button>
|
|
||||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroupContent>
|
|
||||||
</SidebarGroup>
|
|
||||||
</SidebarContent>
|
|
||||||
</Sidebar>
|
|
||||||
</SidebarProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,778 +0,0 @@
|
|||||||
import React, {useState, useRef, useEffect} from "react";
|
|
||||||
import {SSHSidebar} from "@/apps/SSH/Terminal/SSHSidebar.tsx";
|
|
||||||
import {SSHTerminal} from "./SSHTerminal.tsx";
|
|
||||||
import {SSHTopbar} from "@/apps/SSH/Terminal/SSHTopbar.tsx";
|
|
||||||
import {ResizablePanelGroup, ResizablePanel, ResizableHandle} from '@/components/ui/resizable.tsx';
|
|
||||||
import * as ResizablePrimitive from "react-resizable-panels";
|
|
||||||
|
|
||||||
interface ConfigEditorProps {
|
|
||||||
onSelectView: (view: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Tab = {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
hostConfig: any;
|
|
||||||
terminalRef: React.RefObject<any>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function SSH({onSelectView}: ConfigEditorProps): React.ReactElement {
|
|
||||||
const [allTabs, setAllTabs] = useState<Tab[]>([]);
|
|
||||||
const [currentTab, setCurrentTab] = useState<number | null>(null);
|
|
||||||
const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]);
|
|
||||||
const nextTabId = useRef(1);
|
|
||||||
|
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(true);
|
|
||||||
const [isTopbarOpen, setIsTopbarOpen] = useState<boolean>(true);
|
|
||||||
const SIDEBAR_WIDTH = 256;
|
|
||||||
const HANDLE_THICKNESS = 6;
|
|
||||||
|
|
||||||
const [panelRects, setPanelRects] = useState<Record<string, DOMRect | null>>({});
|
|
||||||
const panelRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
|
||||||
const panelGroupRefs = useRef<{ [key: string]: any }>({});
|
|
||||||
|
|
||||||
const setActiveTab = (tabId: number) => {
|
|
||||||
setCurrentTab(tabId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const fitVisibleTerminals = () => {
|
|
||||||
allTabs.forEach((terminal) => {
|
|
||||||
const isVisible =
|
|
||||||
(allSplitScreenTab.length === 0 && terminal.id === currentTab) ||
|
|
||||||
(allSplitScreenTab.length > 0 && (terminal.id === currentTab || allSplitScreenTab.includes(terminal.id)));
|
|
||||||
if (isVisible && terminal.terminalRef && terminal.terminalRef.current && typeof terminal.terminalRef.current.fit === 'function') {
|
|
||||||
terminal.terminalRef.current.fit();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const setSplitScreenTab = (tabId: number) => {
|
|
||||||
fitVisibleTerminals();
|
|
||||||
setAllSplitScreenTab((prev) => {
|
|
||||||
let next;
|
|
||||||
if (prev.includes(tabId)) {
|
|
||||||
next = prev.filter((id) => id !== tabId);
|
|
||||||
} else if (prev.length < 3) {
|
|
||||||
next = [...prev, tabId];
|
|
||||||
} else {
|
|
||||||
next = prev;
|
|
||||||
}
|
|
||||||
setTimeout(() => fitVisibleTerminals(), 0);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const setCloseTab = (tabId: number) => {
|
|
||||||
const tab = allTabs.find((t) => t.id === tabId);
|
|
||||||
if (tab && tab.terminalRef && tab.terminalRef.current && typeof tab.terminalRef.current.disconnect === "function") {
|
|
||||||
tab.terminalRef.current.disconnect();
|
|
||||||
}
|
|
||||||
setAllTabs((prev) => prev.filter((tab) => tab.id !== tabId));
|
|
||||||
setAllSplitScreenTab((prev) => prev.filter((id) => id !== tabId));
|
|
||||||
if (currentTab === tabId) {
|
|
||||||
const remainingTabs = allTabs.filter((tab) => tab.id !== tabId);
|
|
||||||
setCurrentTab(remainingTabs.length > 0 ? remainingTabs[0].id : null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updatePanelRects = () => {
|
|
||||||
setPanelRects((prev) => {
|
|
||||||
const next: Record<string, DOMRect | null> = {...prev};
|
|
||||||
Object.entries(panelRefs.current).forEach(([id, ref]) => {
|
|
||||||
if (ref) {
|
|
||||||
next[id] = ref.getBoundingClientRect();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const observers: ResizeObserver[] = [];
|
|
||||||
Object.entries(panelRefs.current).forEach(([id, ref]) => {
|
|
||||||
if (ref) {
|
|
||||||
const observer = new ResizeObserver(() => updatePanelRects());
|
|
||||||
observer.observe(ref);
|
|
||||||
observers.push(observer);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
updatePanelRects();
|
|
||||||
return () => {
|
|
||||||
observers.forEach((observer) => observer.disconnect());
|
|
||||||
};
|
|
||||||
}, [allSplitScreenTab, currentTab, allTabs.length]);
|
|
||||||
|
|
||||||
const renderAllTerminals = () => {
|
|
||||||
const layoutStyles: Record<number, React.CSSProperties> = {};
|
|
||||||
const splitTabs = allTabs.filter((tab) => allSplitScreenTab.includes(tab.id));
|
|
||||||
const mainTab = allTabs.find((tab) => tab.id === currentTab);
|
|
||||||
const layoutTabs = [mainTab, ...splitTabs.filter((t) => t && t.id !== (mainTab && mainTab.id))].filter((t): t is Tab => !!t);
|
|
||||||
if (allSplitScreenTab.length === 0 && mainTab) {
|
|
||||||
layoutStyles[mainTab.id] = {
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
zIndex: 20,
|
|
||||||
display: 'block',
|
|
||||||
pointerEvents: 'auto',
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
layoutTabs.forEach((tab) => {
|
|
||||||
const rect = panelRects[String(tab.id)];
|
|
||||||
if (rect) {
|
|
||||||
const parentRect = panelRefs.current['parent']?.getBoundingClientRect();
|
|
||||||
let top = rect.top, left = rect.left, width = rect.width, height = rect.height;
|
|
||||||
if (parentRect) {
|
|
||||||
top = rect.top - parentRect.top;
|
|
||||||
left = rect.left - parentRect.left;
|
|
||||||
}
|
|
||||||
layoutStyles[tab.id] = {
|
|
||||||
position: 'absolute',
|
|
||||||
top: top + 28,
|
|
||||||
left,
|
|
||||||
width,
|
|
||||||
height: height - 28,
|
|
||||||
zIndex: 20,
|
|
||||||
display: 'block',
|
|
||||||
pointerEvents: 'auto',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div ref={el => {
|
|
||||||
panelRefs.current['parent'] = el;
|
|
||||||
}} style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
zIndex: 1,
|
|
||||||
overflow: 'hidden'
|
|
||||||
}}>
|
|
||||||
{allTabs.map((tab) => {
|
|
||||||
const style = layoutStyles[tab.id]
|
|
||||||
? {...layoutStyles[tab.id], overflow: 'hidden'}
|
|
||||||
: {display: 'none', overflow: 'hidden'};
|
|
||||||
const isVisible = !!layoutStyles[tab.id];
|
|
||||||
return (
|
|
||||||
<div key={tab.id} style={style} data-terminal-id={tab.id}>
|
|
||||||
<SSHTerminal
|
|
||||||
key={tab.id}
|
|
||||||
ref={tab.terminalRef}
|
|
||||||
hostConfig={tab.hostConfig}
|
|
||||||
isVisible={isVisible}
|
|
||||||
title={tab.title}
|
|
||||||
showTitle={false}
|
|
||||||
splitScreen={allSplitScreenTab.length > 0}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderSplitOverlays = () => {
|
|
||||||
const splitTabs = allTabs.filter((tab) => allSplitScreenTab.includes(tab.id));
|
|
||||||
const mainTab = allTabs.find((tab) => tab.id === currentTab);
|
|
||||||
const layoutTabs = [mainTab, ...splitTabs.filter((t) => t && t.id !== (mainTab && mainTab.id))].filter((t): t is Tab => !!t);
|
|
||||||
if (allSplitScreenTab.length === 0) return null;
|
|
||||||
|
|
||||||
if (layoutTabs.length === 2) {
|
|
||||||
const [tab1, tab2] = layoutTabs;
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
zIndex: 10,
|
|
||||||
pointerEvents: 'none'
|
|
||||||
}}>
|
|
||||||
<ResizablePrimitive.PanelGroup
|
|
||||||
ref={el => {
|
|
||||||
panelGroupRefs.current['main'] = el;
|
|
||||||
}}
|
|
||||||
direction="horizontal"
|
|
||||||
className="h-full w-full"
|
|
||||||
id="main-horizontal"
|
|
||||||
>
|
|
||||||
<ResizablePanel key={tab1.id} defaultSize={50} minSize={20}
|
|
||||||
className="!overflow-hidden h-full w-full" id={`panel-${tab1.id}`} order={1}>
|
|
||||||
<div ref={el => {
|
|
||||||
panelRefs.current[String(tab1.id)] = el;
|
|
||||||
}} style={{
|
|
||||||
height: '100%',
|
|
||||||
width: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
background: 'transparent',
|
|
||||||
margin: 0,
|
|
||||||
padding: 0,
|
|
||||||
position: 'relative'
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
background: '#18181b',
|
|
||||||
color: '#fff',
|
|
||||||
fontSize: 13,
|
|
||||||
height: 28,
|
|
||||||
lineHeight: '28px',
|
|
||||||
padding: '0 10px',
|
|
||||||
borderBottom: '1px solid #222224',
|
|
||||||
letterSpacing: 1,
|
|
||||||
margin: 0,
|
|
||||||
pointerEvents: 'auto',
|
|
||||||
zIndex: 11,
|
|
||||||
}}>{tab1.title}</div>
|
|
||||||
</div>
|
|
||||||
</ResizablePanel>
|
|
||||||
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
|
|
||||||
<ResizablePanel key={tab2.id} defaultSize={50} minSize={20}
|
|
||||||
className="!overflow-hidden h-full w-full" id={`panel-${tab2.id}`} order={2}>
|
|
||||||
<div ref={el => {
|
|
||||||
panelRefs.current[String(tab2.id)] = el;
|
|
||||||
}} style={{
|
|
||||||
height: '100%',
|
|
||||||
width: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
background: 'transparent',
|
|
||||||
margin: 0,
|
|
||||||
padding: 0,
|
|
||||||
position: 'relative'
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
background: '#18181b',
|
|
||||||
color: '#fff',
|
|
||||||
fontSize: 13,
|
|
||||||
height: 28,
|
|
||||||
lineHeight: '28px',
|
|
||||||
padding: '0 10px',
|
|
||||||
borderBottom: '1px solid #222224',
|
|
||||||
letterSpacing: 1,
|
|
||||||
margin: 0,
|
|
||||||
pointerEvents: 'auto',
|
|
||||||
zIndex: 11,
|
|
||||||
}}>{tab2.title}</div>
|
|
||||||
</div>
|
|
||||||
</ResizablePanel>
|
|
||||||
</ResizablePrimitive.PanelGroup>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (layoutTabs.length === 3) {
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
zIndex: 10,
|
|
||||||
pointerEvents: 'none'
|
|
||||||
}}>
|
|
||||||
<ResizablePrimitive.PanelGroup
|
|
||||||
ref={el => {
|
|
||||||
panelGroupRefs.current['main'] = el;
|
|
||||||
}}
|
|
||||||
direction="vertical"
|
|
||||||
className="h-full w-full"
|
|
||||||
id="main-vertical"
|
|
||||||
>
|
|
||||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
|
|
||||||
id="top-panel" order={1}>
|
|
||||||
<ResizablePanelGroup ref={el => {
|
|
||||||
panelGroupRefs.current['top'] = el;
|
|
||||||
}} direction="horizontal" className="h-full w-full" id="top-horizontal">
|
|
||||||
<ResizablePanel key={layoutTabs[0].id} defaultSize={50} minSize={20}
|
|
||||||
className="!overflow-hidden h-full w-full"
|
|
||||||
id={`panel-${layoutTabs[0].id}`} order={1}>
|
|
||||||
<div ref={el => {
|
|
||||||
panelRefs.current[String(layoutTabs[0].id)] = el;
|
|
||||||
}} style={{
|
|
||||||
height: '100%',
|
|
||||||
width: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
background: 'transparent',
|
|
||||||
margin: 0,
|
|
||||||
padding: 0,
|
|
||||||
position: 'relative'
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
background: '#18181b',
|
|
||||||
color: '#fff',
|
|
||||||
fontSize: 13,
|
|
||||||
height: 28,
|
|
||||||
lineHeight: '28px',
|
|
||||||
padding: '0 10px',
|
|
||||||
borderBottom: '1px solid #222224',
|
|
||||||
letterSpacing: 1,
|
|
||||||
margin: 0,
|
|
||||||
pointerEvents: 'auto',
|
|
||||||
zIndex: 11,
|
|
||||||
}}>{layoutTabs[0].title}</div>
|
|
||||||
</div>
|
|
||||||
</ResizablePanel>
|
|
||||||
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
|
|
||||||
<ResizablePanel key={layoutTabs[1].id} defaultSize={50} minSize={20}
|
|
||||||
className="!overflow-hidden h-full w-full"
|
|
||||||
id={`panel-${layoutTabs[1].id}`} order={2}>
|
|
||||||
<div ref={el => {
|
|
||||||
panelRefs.current[String(layoutTabs[1].id)] = el;
|
|
||||||
}} style={{
|
|
||||||
height: '100%',
|
|
||||||
width: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
background: 'transparent',
|
|
||||||
margin: 0,
|
|
||||||
padding: 0,
|
|
||||||
position: 'relative'
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
background: '#18181b',
|
|
||||||
color: '#fff',
|
|
||||||
fontSize: 13,
|
|
||||||
height: 28,
|
|
||||||
lineHeight: '28px',
|
|
||||||
padding: '0 10px',
|
|
||||||
borderBottom: '1px solid #222224',
|
|
||||||
letterSpacing: 1,
|
|
||||||
margin: 0,
|
|
||||||
pointerEvents: 'auto',
|
|
||||||
zIndex: 11,
|
|
||||||
}}>{layoutTabs[1].title}</div>
|
|
||||||
</div>
|
|
||||||
</ResizablePanel>
|
|
||||||
</ResizablePanelGroup>
|
|
||||||
</ResizablePanel>
|
|
||||||
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
|
|
||||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
|
|
||||||
id="bottom-panel" order={2}>
|
|
||||||
<div ref={el => {
|
|
||||||
panelRefs.current[String(layoutTabs[2].id)] = el;
|
|
||||||
}} style={{
|
|
||||||
height: '100%',
|
|
||||||
width: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
background: 'transparent',
|
|
||||||
margin: 0,
|
|
||||||
padding: 0,
|
|
||||||
position: 'relative'
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
background: '#18181b',
|
|
||||||
color: '#fff',
|
|
||||||
fontSize: 13,
|
|
||||||
height: 28,
|
|
||||||
lineHeight: '28px',
|
|
||||||
padding: '0 10px',
|
|
||||||
borderBottom: '1px solid #222224',
|
|
||||||
letterSpacing: 1,
|
|
||||||
margin: 0,
|
|
||||||
pointerEvents: 'auto',
|
|
||||||
zIndex: 11,
|
|
||||||
}}>{layoutTabs[2].title}</div>
|
|
||||||
</div>
|
|
||||||
</ResizablePanel>
|
|
||||||
</ResizablePrimitive.PanelGroup>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (layoutTabs.length === 4) {
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
zIndex: 10,
|
|
||||||
pointerEvents: 'none'
|
|
||||||
}}>
|
|
||||||
<ResizablePrimitive.PanelGroup
|
|
||||||
ref={el => {
|
|
||||||
panelGroupRefs.current['main'] = el;
|
|
||||||
}}
|
|
||||||
direction="vertical"
|
|
||||||
className="h-full w-full"
|
|
||||||
id="main-vertical"
|
|
||||||
>
|
|
||||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
|
|
||||||
id="top-panel" order={1}>
|
|
||||||
<ResizablePanelGroup ref={el => {
|
|
||||||
panelGroupRefs.current['top'] = el;
|
|
||||||
}} direction="horizontal" className="h-full w-full" id="top-horizontal">
|
|
||||||
<ResizablePanel key={layoutTabs[0].id} defaultSize={50} minSize={20}
|
|
||||||
className="!overflow-hidden h-full w-full"
|
|
||||||
id={`panel-${layoutTabs[0].id}`} order={1}>
|
|
||||||
<div ref={el => {
|
|
||||||
panelRefs.current[String(layoutTabs[0].id)] = el;
|
|
||||||
}} style={{
|
|
||||||
height: '100%',
|
|
||||||
width: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
background: 'transparent',
|
|
||||||
margin: 0,
|
|
||||||
padding: 0,
|
|
||||||
position: 'relative'
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
background: '#18181b',
|
|
||||||
color: '#fff',
|
|
||||||
fontSize: 13,
|
|
||||||
height: 28,
|
|
||||||
lineHeight: '28px',
|
|
||||||
padding: '0 10px',
|
|
||||||
borderBottom: '1px solid #222224',
|
|
||||||
letterSpacing: 1,
|
|
||||||
margin: 0,
|
|
||||||
pointerEvents: 'auto',
|
|
||||||
zIndex: 11,
|
|
||||||
}}>{layoutTabs[0].title}</div>
|
|
||||||
</div>
|
|
||||||
</ResizablePanel>
|
|
||||||
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
|
|
||||||
<ResizablePanel key={layoutTabs[1].id} defaultSize={50} minSize={20}
|
|
||||||
className="!overflow-hidden h-full w-full"
|
|
||||||
id={`panel-${layoutTabs[1].id}`} order={2}>
|
|
||||||
<div ref={el => {
|
|
||||||
panelRefs.current[String(layoutTabs[1].id)] = el;
|
|
||||||
}} style={{
|
|
||||||
height: '100%',
|
|
||||||
width: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
background: 'transparent',
|
|
||||||
margin: 0,
|
|
||||||
padding: 0,
|
|
||||||
position: 'relative'
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
background: '#18181b',
|
|
||||||
color: '#fff',
|
|
||||||
fontSize: 13,
|
|
||||||
height: 28,
|
|
||||||
lineHeight: '28px',
|
|
||||||
padding: '0 10px',
|
|
||||||
borderBottom: '1px solid #222224',
|
|
||||||
letterSpacing: 1,
|
|
||||||
margin: 0,
|
|
||||||
pointerEvents: 'auto',
|
|
||||||
zIndex: 11,
|
|
||||||
}}>{layoutTabs[1].title}</div>
|
|
||||||
</div>
|
|
||||||
</ResizablePanel>
|
|
||||||
</ResizablePanelGroup>
|
|
||||||
</ResizablePanel>
|
|
||||||
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
|
|
||||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
|
|
||||||
id="bottom-panel" order={2}>
|
|
||||||
<ResizablePanelGroup ref={el => {
|
|
||||||
panelGroupRefs.current['bottom'] = el;
|
|
||||||
}} direction="horizontal" className="h-full w-full" id="bottom-horizontal">
|
|
||||||
<ResizablePanel key={layoutTabs[2].id} defaultSize={50} minSize={20}
|
|
||||||
className="!overflow-hidden h-full w-full"
|
|
||||||
id={`panel-${layoutTabs[2].id}`} order={1}>
|
|
||||||
<div ref={el => {
|
|
||||||
panelRefs.current[String(layoutTabs[2].id)] = el;
|
|
||||||
}} style={{
|
|
||||||
height: '100%',
|
|
||||||
width: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
background: 'transparent',
|
|
||||||
margin: 0,
|
|
||||||
padding: 0,
|
|
||||||
position: 'relative'
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
background: '#18181b',
|
|
||||||
color: '#fff',
|
|
||||||
fontSize: 13,
|
|
||||||
height: 28,
|
|
||||||
lineHeight: '28px',
|
|
||||||
padding: '0 10px',
|
|
||||||
borderBottom: '1px solid #222224',
|
|
||||||
letterSpacing: 1,
|
|
||||||
margin: 0,
|
|
||||||
pointerEvents: 'auto',
|
|
||||||
zIndex: 11,
|
|
||||||
}}>{layoutTabs[2].title}</div>
|
|
||||||
</div>
|
|
||||||
</ResizablePanel>
|
|
||||||
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
|
|
||||||
<ResizablePanel key={layoutTabs[3].id} defaultSize={50} minSize={20}
|
|
||||||
className="!overflow-hidden h-full w-full"
|
|
||||||
id={`panel-${layoutTabs[3].id}`} order={2}>
|
|
||||||
<div ref={el => {
|
|
||||||
panelRefs.current[String(layoutTabs[3].id)] = el;
|
|
||||||
}} style={{
|
|
||||||
height: '100%',
|
|
||||||
width: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
background: 'transparent',
|
|
||||||
margin: 0,
|
|
||||||
padding: 0,
|
|
||||||
position: 'relative'
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
background: '#18181b',
|
|
||||||
color: '#fff',
|
|
||||||
fontSize: 13,
|
|
||||||
height: 28,
|
|
||||||
lineHeight: '28px',
|
|
||||||
padding: '0 10px',
|
|
||||||
borderBottom: '1px solid #222224',
|
|
||||||
letterSpacing: 1,
|
|
||||||
margin: 0,
|
|
||||||
pointerEvents: 'auto',
|
|
||||||
zIndex: 11,
|
|
||||||
}}>{layoutTabs[3].title}</div>
|
|
||||||
</div>
|
|
||||||
</ResizablePanel>
|
|
||||||
</ResizablePanelGroup>
|
|
||||||
</ResizablePanel>
|
|
||||||
</ResizablePrimitive.PanelGroup>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onAddHostSubmit = (data: any) => {
|
|
||||||
const id = nextTabId.current++;
|
|
||||||
const title = `${data.ip || "Host"}:${data.port || 22}`;
|
|
||||||
const terminalRef = React.createRef<any>();
|
|
||||||
const newTab: Tab = {
|
|
||||||
id,
|
|
||||||
title,
|
|
||||||
hostConfig: data,
|
|
||||||
terminalRef,
|
|
||||||
};
|
|
||||||
setAllTabs((prev) => [...prev, newTab]);
|
|
||||||
setCurrentTab(id);
|
|
||||||
setAllSplitScreenTab((prev) => prev.filter((tid) => tid !== id));
|
|
||||||
};
|
|
||||||
|
|
||||||
const getUniqueTabTitle = (baseTitle: string) => {
|
|
||||||
let title = baseTitle;
|
|
||||||
let count = 1;
|
|
||||||
const existingTitles = allTabs.map(t => t.title);
|
|
||||||
while (existingTitles.includes(title)) {
|
|
||||||
title = `${baseTitle} (${count})`;
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
return title;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onHostConnect = (hostConfig: any) => {
|
|
||||||
const baseTitle = hostConfig.name?.trim() ? hostConfig.name : `${hostConfig.ip || "Host"}:${hostConfig.port || 22}`;
|
|
||||||
const title = getUniqueTabTitle(baseTitle);
|
|
||||||
const terminalRef = React.createRef<any>();
|
|
||||||
const id = nextTabId.current++;
|
|
||||||
const newTab: Tab = {
|
|
||||||
id,
|
|
||||||
title,
|
|
||||||
hostConfig,
|
|
||||||
terminalRef,
|
|
||||||
};
|
|
||||||
setAllTabs((prev) => [...prev, newTab]);
|
|
||||||
setCurrentTab(id);
|
|
||||||
setAllSplitScreenTab((prev) => prev.filter((tid) => tid !== id));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{display: 'flex', width: '100vw', height: '100vh', overflow: 'hidden', position: 'relative'}}>
|
|
||||||
{/* Sidebar (collapsible) */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: isSidebarOpen ? SIDEBAR_WIDTH : 0,
|
|
||||||
flexShrink: 0,
|
|
||||||
height: '100vh',
|
|
||||||
position: 'relative',
|
|
||||||
zIndex: 2,
|
|
||||||
margin: 0,
|
|
||||||
padding: 0,
|
|
||||||
border: 'none',
|
|
||||||
overflow: 'hidden',
|
|
||||||
transition: 'width 240ms ease-in-out',
|
|
||||||
willChange: 'width',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SSHSidebar
|
|
||||||
onSelectView={onSelectView}
|
|
||||||
onHostConnect={onHostConnect}
|
|
||||||
allTabs={allTabs}
|
|
||||||
runCommandOnTabs={(tabIds: number[], command: string) => {
|
|
||||||
allTabs.forEach(tab => {
|
|
||||||
if (tabIds.includes(tab.id) && tab.terminalRef?.current?.sendInput) {
|
|
||||||
tab.terminalRef.current.sendInput(command);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
onCloseSidebar={() => setIsSidebarOpen(false)}
|
|
||||||
open={isSidebarOpen}
|
|
||||||
onOpenChange={setIsSidebarOpen}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="terminal-container"
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
height: '100vh',
|
|
||||||
position: 'relative',
|
|
||||||
overflow: 'hidden',
|
|
||||||
margin: 0,
|
|
||||||
padding: 0,
|
|
||||||
paddingLeft: isSidebarOpen ? 0 : HANDLE_THICKNESS,
|
|
||||||
paddingTop: isTopbarOpen ? 0 : HANDLE_THICKNESS,
|
|
||||||
border: 'none',
|
|
||||||
transition: 'padding-left 240ms ease-in-out, padding-top 240ms ease-in-out',
|
|
||||||
willChange: 'padding',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100%',
|
|
||||||
height: isTopbarOpen ? 46 : 0,
|
|
||||||
overflow: 'hidden',
|
|
||||||
zIndex: 10,
|
|
||||||
transition: 'height 240ms ease-in-out',
|
|
||||||
willChange: 'height',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SSHTopbar
|
|
||||||
allTabs={allTabs}
|
|
||||||
currentTab={currentTab ?? -1}
|
|
||||||
setActiveTab={setActiveTab}
|
|
||||||
allSplitScreenTab={allSplitScreenTab}
|
|
||||||
setSplitScreenTab={setSplitScreenTab}
|
|
||||||
setCloseTab={setCloseTab}
|
|
||||||
onHideTopbar={() => setIsTopbarOpen(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{!isTopbarOpen && (
|
|
||||||
<div
|
|
||||||
onClick={() => setIsTopbarOpen(true)}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100%',
|
|
||||||
height: HANDLE_THICKNESS,
|
|
||||||
background: '#222224',
|
|
||||||
cursor: 'pointer',
|
|
||||||
zIndex: 12,
|
|
||||||
}}
|
|
||||||
title="Show top bar"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Main terminal area (height adapts to topbar) */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: isTopbarOpen ? 'calc(100% - 46px)' : '100%',
|
|
||||||
marginTop: isTopbarOpen ? 46 : 0,
|
|
||||||
position: 'relative',
|
|
||||||
transition: 'margin-top 240ms ease-in-out, height 240ms ease-in-out',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{allTabs.length === 0 && (
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: '50%',
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translate(-50%, -50%)',
|
|
||||||
background: '#18181b',
|
|
||||||
border: '1px solid #434345',
|
|
||||||
borderRadius: '8px',
|
|
||||||
padding: '24px',
|
|
||||||
textAlign: 'center',
|
|
||||||
color: '#f7f7f7',
|
|
||||||
maxWidth: '400px',
|
|
||||||
zIndex: 30
|
|
||||||
}}>
|
|
||||||
<div style={{fontSize: '18px', fontWeight: 'bold', marginBottom: '12px'}}>
|
|
||||||
Welcome to Termix SSH
|
|
||||||
</div>
|
|
||||||
<div style={{fontSize: '14px', color: '#a1a1aa', lineHeight: '1.5'}}>
|
|
||||||
Click on any host title in the sidebar to open a terminal connection, or use the "Add
|
|
||||||
Host" button to create a new connection.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{allSplitScreenTab.length > 0 && (
|
|
||||||
<div style={{position: 'absolute', top: 0, right: 0, zIndex: 20, height: 28}}>
|
|
||||||
<button
|
|
||||||
style={{
|
|
||||||
background: '#18181b',
|
|
||||||
color: '#fff',
|
|
||||||
borderLeft: '1px solid #222224',
|
|
||||||
borderRight: '1px solid #222224',
|
|
||||||
borderTop: 'none',
|
|
||||||
borderBottom: '1px solid #222224',
|
|
||||||
borderRadius: 0,
|
|
||||||
padding: '2px 10px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: 13,
|
|
||||||
margin: 0,
|
|
||||||
height: 28,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
if (allSplitScreenTab.length === 1) {
|
|
||||||
panelGroupRefs.current['main']?.setLayout([50, 50]);
|
|
||||||
} else if (allSplitScreenTab.length === 2) {
|
|
||||||
panelGroupRefs.current['main']?.setLayout([50, 50]);
|
|
||||||
panelGroupRefs.current['top']?.setLayout([50, 50]);
|
|
||||||
} else if (allSplitScreenTab.length === 3) {
|
|
||||||
panelGroupRefs.current['main']?.setLayout([50, 50]);
|
|
||||||
panelGroupRefs.current['top']?.setLayout([50, 50]);
|
|
||||||
panelGroupRefs.current['bottom']?.setLayout([50, 50]);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Reset Split Sizes
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{renderAllTerminals()}
|
|
||||||
{renderSplitOverlays()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sidebar reopen handle */}
|
|
||||||
{!isSidebarOpen && (
|
|
||||||
<div
|
|
||||||
onClick={() => setIsSidebarOpen(true)}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: HANDLE_THICKNESS,
|
|
||||||
height: '100%',
|
|
||||||
background: '#222224',
|
|
||||||
cursor: 'pointer',
|
|
||||||
zIndex: 20,
|
|
||||||
}}
|
|
||||||
title="Show sidebar"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,440 +0,0 @@
|
|||||||
import React, {useState} from 'react';
|
|
||||||
|
|
||||||
import {
|
|
||||||
CornerDownLeft,
|
|
||||||
Hammer, Pin, Menu
|
|
||||||
} from "lucide-react"
|
|
||||||
|
|
||||||
import {
|
|
||||||
Button
|
|
||||||
} from "@/components/ui/button.tsx"
|
|
||||||
|
|
||||||
import {
|
|
||||||
Sidebar,
|
|
||||||
SidebarContent,
|
|
||||||
SidebarGroup,
|
|
||||||
SidebarGroupContent,
|
|
||||||
SidebarGroupLabel,
|
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuItem, SidebarProvider,
|
|
||||||
} from "@/components/ui/sidebar.tsx"
|
|
||||||
|
|
||||||
import {
|
|
||||||
Separator,
|
|
||||||
} from "@/components/ui/separator.tsx"
|
|
||||||
import {
|
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
SheetTrigger
|
|
||||||
} from "@/components/ui/sheet.tsx";
|
|
||||||
import {
|
|
||||||
Accordion,
|
|
||||||
AccordionContent,
|
|
||||||
AccordionItem,
|
|
||||||
AccordionTrigger,
|
|
||||||
} from "@/components/ui/accordion.tsx";
|
|
||||||
import {ScrollArea} from "@/components/ui/scroll-area.tsx";
|
|
||||||
import {Input} from "@/components/ui/input.tsx";
|
|
||||||
import {getSSHHosts} from "@/apps/SSH/ssh-axios";
|
|
||||||
import {Checkbox} from "@/components/ui/checkbox.tsx";
|
|
||||||
|
|
||||||
interface SSHHost {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
ip: string;
|
|
||||||
port: number;
|
|
||||||
username: string;
|
|
||||||
folder: string;
|
|
||||||
tags: string[];
|
|
||||||
pin: boolean;
|
|
||||||
authType: string;
|
|
||||||
password?: string;
|
|
||||||
key?: string;
|
|
||||||
keyPassword?: string;
|
|
||||||
keyType?: string;
|
|
||||||
enableTerminal: boolean;
|
|
||||||
enableTunnel: boolean;
|
|
||||||
enableConfigEditor: boolean;
|
|
||||||
defaultPath: string;
|
|
||||||
tunnelConnections: any[];
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SidebarProps {
|
|
||||||
onSelectView: (view: string) => void;
|
|
||||||
onHostConnect: (hostConfig: any) => void;
|
|
||||||
allTabs: { id: number; title: string; terminalRef: React.RefObject<any> }[];
|
|
||||||
runCommandOnTabs: (tabIds: number[], command: string) => void;
|
|
||||||
onCloseSidebar?: () => void;
|
|
||||||
onAddHostSubmit?: (data: any) => void;
|
|
||||||
open?: boolean;
|
|
||||||
onOpenChange?: (open: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SSHSidebar({
|
|
||||||
onSelectView,
|
|
||||||
onHostConnect,
|
|
||||||
allTabs,
|
|
||||||
runCommandOnTabs,
|
|
||||||
onCloseSidebar,
|
|
||||||
open,
|
|
||||||
onOpenChange
|
|
||||||
}: SidebarProps): React.ReactElement {
|
|
||||||
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
|
||||||
const [hostsLoading, setHostsLoading] = useState(false);
|
|
||||||
const [hostsError, setHostsError] = useState<string | null>(null);
|
|
||||||
const prevHostsRef = React.useRef<SSHHost[]>([]);
|
|
||||||
|
|
||||||
const fetchHosts = React.useCallback(async () => {
|
|
||||||
setHostsLoading(true);
|
|
||||||
setHostsError(null);
|
|
||||||
try {
|
|
||||||
const newHosts = await getSSHHosts();
|
|
||||||
const terminalHosts = newHosts.filter(host => host.enableTerminal);
|
|
||||||
|
|
||||||
const prevHosts = prevHostsRef.current;
|
|
||||||
const isSame =
|
|
||||||
terminalHosts.length === prevHosts.length &&
|
|
||||||
terminalHosts.every((h: SSHHost, i: number) => {
|
|
||||||
const prev = prevHosts[i];
|
|
||||||
if (!prev) return false;
|
|
||||||
return (
|
|
||||||
h.id === prev.id &&
|
|
||||||
h.name === prev.name &&
|
|
||||||
h.folder === prev.folder &&
|
|
||||||
h.ip === prev.ip &&
|
|
||||||
h.port === prev.port &&
|
|
||||||
h.username === prev.username &&
|
|
||||||
h.password === prev.password &&
|
|
||||||
h.authType === prev.authType &&
|
|
||||||
h.key === prev.key &&
|
|
||||||
h.pin === prev.pin &&
|
|
||||||
JSON.stringify(h.tags) === JSON.stringify(prev.tags)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
if (!isSame) {
|
|
||||||
setHosts(terminalHosts);
|
|
||||||
prevHostsRef.current = terminalHosts;
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
setHostsError('Failed to load hosts');
|
|
||||||
} finally {
|
|
||||||
setHostsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
fetchHosts();
|
|
||||||
const interval = setInterval(fetchHosts, 10000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [fetchHosts]);
|
|
||||||
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
|
||||||
React.useEffect(() => {
|
|
||||||
const handler = setTimeout(() => setDebouncedSearch(search), 200);
|
|
||||||
return () => clearTimeout(handler);
|
|
||||||
}, [search]);
|
|
||||||
|
|
||||||
const filteredHosts = React.useMemo(() => {
|
|
||||||
if (!debouncedSearch.trim()) return hosts;
|
|
||||||
const q = debouncedSearch.trim().toLowerCase();
|
|
||||||
return hosts.filter(h => {
|
|
||||||
const searchableText = [
|
|
||||||
h.name || '',
|
|
||||||
h.username,
|
|
||||||
h.ip,
|
|
||||||
h.folder || '',
|
|
||||||
...(h.tags || []),
|
|
||||||
h.authType,
|
|
||||||
h.defaultPath || ''
|
|
||||||
].join(' ').toLowerCase();
|
|
||||||
return searchableText.includes(q);
|
|
||||||
});
|
|
||||||
}, [hosts, debouncedSearch]);
|
|
||||||
|
|
||||||
const hostsByFolder = React.useMemo(() => {
|
|
||||||
const map: Record<string, SSHHost[]> = {};
|
|
||||||
filteredHosts.forEach(h => {
|
|
||||||
const folder = h.folder && h.folder.trim() ? h.folder : 'No Folder';
|
|
||||||
if (!map[folder]) map[folder] = [];
|
|
||||||
map[folder].push(h);
|
|
||||||
});
|
|
||||||
return map;
|
|
||||||
}, [filteredHosts]);
|
|
||||||
|
|
||||||
const sortedFolders = React.useMemo(() => {
|
|
||||||
const folders = Object.keys(hostsByFolder);
|
|
||||||
folders.sort((a, b) => {
|
|
||||||
if (a === 'No Folder') return -1;
|
|
||||||
if (b === 'No Folder') return 1;
|
|
||||||
return a.localeCompare(b);
|
|
||||||
});
|
|
||||||
return folders;
|
|
||||||
}, [hostsByFolder]);
|
|
||||||
|
|
||||||
const getSortedHosts = (arr: SSHHost[]) => {
|
|
||||||
const pinned = arr.filter(h => h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
|
||||||
const rest = arr.filter(h => !h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
|
||||||
return [...pinned, ...rest];
|
|
||||||
};
|
|
||||||
|
|
||||||
const [toolsSheetOpen, setToolsSheetOpen] = useState(false);
|
|
||||||
const [toolsCommand, setToolsCommand] = useState("");
|
|
||||||
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
|
|
||||||
|
|
||||||
const handleTabToggle = (tabId: number) => {
|
|
||||||
setSelectedTabIds(prev => prev.includes(tabId) ? prev.filter(id => id !== tabId) : [...prev, tabId]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRunCommand = () => {
|
|
||||||
if (selectedTabIds.length && toolsCommand.trim()) {
|
|
||||||
let cmd = toolsCommand;
|
|
||||||
if (!cmd.endsWith("\n")) cmd += "\n";
|
|
||||||
runCommandOnTabs(selectedTabIds, cmd);
|
|
||||||
setToolsCommand("");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function getCookie(name: string) {
|
|
||||||
return document.cookie.split('; ').reduce((r, v) => {
|
|
||||||
const parts = v.split('=');
|
|
||||||
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
|
|
||||||
}, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateRightClickCopyPaste = (checked) => {
|
|
||||||
document.cookie = `rightClickCopyPaste=${checked}; expires=2147483647; path=/`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarProvider open={open} onOpenChange={onOpenChange}>
|
|
||||||
<Sidebar className="h-full flex flex-col overflow-hidden">
|
|
||||||
<SidebarContent className="flex flex-col flex-grow h-full overflow-hidden">
|
|
||||||
<SidebarGroup className="flex flex-col flex-grow h-full overflow-hidden">
|
|
||||||
<SidebarGroupLabel
|
|
||||||
className="text-lg font-bold text-white flex items-center justify-between gap-2 w-full">
|
|
||||||
<span>Termix / Terminal</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onCloseSidebar?.()}
|
|
||||||
title="Hide sidebar"
|
|
||||||
style={{
|
|
||||||
height: 28,
|
|
||||||
width: 28,
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
background: 'hsl(240 5% 9%)',
|
|
||||||
color: 'hsl(240 5% 64.9%)',
|
|
||||||
border: '1px solid hsl(240 3.7% 15.9%)',
|
|
||||||
borderRadius: 6,
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Menu className="h-4 w-4"/>
|
|
||||||
</button>
|
|
||||||
</SidebarGroupLabel>
|
|
||||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
|
||||||
<SidebarGroupContent className="flex flex-col flex-grow h-full overflow-hidden">
|
|
||||||
<SidebarMenu className="flex flex-col flex-grow h-full overflow-hidden">
|
|
||||||
|
|
||||||
<SidebarMenuItem key="Homepage">
|
|
||||||
<Button
|
|
||||||
className="w-full mt-2 mb-2 h-8"
|
|
||||||
onClick={() => onSelectView("homepage")}
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
<CornerDownLeft/>
|
|
||||||
Return
|
|
||||||
</Button>
|
|
||||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
|
|
||||||
<SidebarMenuItem key="Main" className="flex flex-col flex-grow overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="w-full flex-grow rounded-md bg-[#09090b] border border-[#434345] overflow-hidden p-0 m-0 relative flex flex-col min-h-0">
|
|
||||||
<div className="w-full px-2 pt-2 pb-2 bg-[#09090b] z-10">
|
|
||||||
<Input
|
|
||||||
value={search}
|
|
||||||
onChange={e => setSearch(e.target.value)}
|
|
||||||
placeholder="Search hosts by name, username, IP, folder, tags..."
|
|
||||||
className="w-full h-8 text-sm bg-background border border-border rounded"
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{display: 'flex', justifyContent: 'center'}}>
|
|
||||||
<Separator className="w-full h-px bg-[#434345] my-2"
|
|
||||||
style={{maxWidth: 213, margin: '0 auto'}}/>
|
|
||||||
</div>
|
|
||||||
{hostsError && (
|
|
||||||
<div className="px-2 py-1 mt-2">
|
|
||||||
<div
|
|
||||||
className="text-xs text-red-500 bg-red-500/10 rounded px-2 py-1 border border-red-500/20">{hostsError}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex-1 min-h-0">
|
|
||||||
<ScrollArea className="w-full h-full">
|
|
||||||
<Accordion key={`host-accordion-${sortedFolders.length}`}
|
|
||||||
type="multiple" className="w-full"
|
|
||||||
defaultValue={sortedFolders.length > 0 ? sortedFolders : undefined}>
|
|
||||||
{sortedFolders.map((folder, idx) => (
|
|
||||||
<React.Fragment key={folder}>
|
|
||||||
<AccordionItem value={folder}
|
|
||||||
className={idx === 0 ? "mt-0 !border-b-transparent" : "mt-2 !border-b-transparent"}>
|
|
||||||
<AccordionTrigger
|
|
||||||
className="text-base font-semibold rounded-t-none px-3 py-2"
|
|
||||||
style={{marginTop: idx === 0 ? 0 : undefined}}>{folder}</AccordionTrigger>
|
|
||||||
<AccordionContent
|
|
||||||
className="flex flex-col gap-1 px-3 pb-2 pt-1">
|
|
||||||
{getSortedHosts(hostsByFolder[folder]).map(host => (
|
|
||||||
<div key={host.id}
|
|
||||||
className="w-full overflow-hidden">
|
|
||||||
<HostMenuItem
|
|
||||||
host={host}
|
|
||||||
onHostConnect={onHostConnect}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
{idx < sortedFolders.length - 1 && (
|
|
||||||
<div
|
|
||||||
style={{display: 'flex', justifyContent: 'center'}}>
|
|
||||||
<Separator className="h-px bg-[#434345] my-1"
|
|
||||||
style={{width: 213}}/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</Accordion>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroupContent>
|
|
||||||
<div className="bg-sidebar">
|
|
||||||
<Sheet open={toolsSheetOpen} onOpenChange={setToolsSheetOpen}>
|
|
||||||
<SheetTrigger asChild>
|
|
||||||
<Button
|
|
||||||
className="w-full h-8 mt-2"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setToolsSheetOpen(true)}
|
|
||||||
>
|
|
||||||
<Hammer className="mr-2 h-4 w-4"/>
|
|
||||||
Tools
|
|
||||||
</Button>
|
|
||||||
</SheetTrigger>
|
|
||||||
<SheetContent side="left"
|
|
||||||
className="w-[256px] fixed top-0 left-0 h-full z-[100] flex flex-col">
|
|
||||||
<SheetHeader className="pb-0.5">
|
|
||||||
<SheetTitle>Tools</SheetTitle>
|
|
||||||
</SheetHeader>
|
|
||||||
<div className="flex-1 overflow-y-auto px-2 pt-2">
|
|
||||||
<Accordion type="single" collapsible defaultValue="multiwindow">
|
|
||||||
<AccordionItem value="multiwindow">
|
|
||||||
<AccordionTrigger className="text-base font-semibold">Run multiwindow
|
|
||||||
commands</AccordionTrigger>
|
|
||||||
<AccordionContent>
|
|
||||||
<textarea
|
|
||||||
className="w-full min-h-[120px] max-h-48 rounded-md border border-input text-foreground p-2 text-sm font-mono resize-vertical focus:outline-none focus:ring-0"
|
|
||||||
placeholder="Enter command(s) to run on selected tabs..."
|
|
||||||
value={toolsCommand}
|
|
||||||
onChange={e => setToolsCommand(e.target.value)}
|
|
||||||
style={{
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
marginBottom: 8,
|
|
||||||
background: '#141416'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-wrap gap-2 mb-2">
|
|
||||||
{allTabs.map(tab => (
|
|
||||||
<Button
|
|
||||||
key={tab.id}
|
|
||||||
type="button"
|
|
||||||
variant={selectedTabIds.includes(tab.id) ? "secondary" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
className="rounded-full px-3 py-1 text-xs flex items-center gap-1"
|
|
||||||
onClick={() => handleTabToggle(tab.id)}
|
|
||||||
>
|
|
||||||
{tab.title}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
className="w-full"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleRunCommand}
|
|
||||||
disabled={!toolsCommand.trim() || !selectedTabIds.length}
|
|
||||||
>
|
|
||||||
Run Command
|
|
||||||
</Button>
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
<Separator className="p-0.25"/>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2 mt-5">
|
|
||||||
<Checkbox id="enable-copy-paste" onCheckedChange={updateRightClickCopyPaste}
|
|
||||||
defaultChecked={getCookie("rightClickCopyPaste") === "true"}/>
|
|
||||||
<label
|
|
||||||
htmlFor="enable-paste"
|
|
||||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
|
||||||
>
|
|
||||||
Enable right‑click copy/paste
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
</div>
|
|
||||||
</SidebarGroup>
|
|
||||||
</SidebarContent>
|
|
||||||
</Sidebar>
|
|
||||||
</SidebarProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const HostMenuItem = React.memo(function HostMenuItem({host, onHostConnect}: {
|
|
||||||
host: SSHHost;
|
|
||||||
onHostConnect: (hostConfig: any) => void
|
|
||||||
}) {
|
|
||||||
const tags = Array.isArray(host.tags) ? host.tags : [];
|
|
||||||
const hasTags = tags.length > 0;
|
|
||||||
return (
|
|
||||||
<div className="relative group flex flex-col mb-1 w-full overflow-hidden">
|
|
||||||
<div className={`flex flex-col w-full rounded overflow-hidden border border-[#434345] bg-[#18181b] h-full`}>
|
|
||||||
<div className="flex w-full h-10">
|
|
||||||
<div
|
|
||||||
className="flex items-center h-full px-2 w-full hover:bg-muted transition-colors cursor-pointer"
|
|
||||||
onClick={() => onHostConnect(host)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center w-full">
|
|
||||||
{host.pin &&
|
|
||||||
<Pin className="h-4.5 mr-1 w-4.5 mt-0.5 text-yellow-500 flex-shrink-0"/>
|
|
||||||
}
|
|
||||||
<span className="font-medium truncate">{host.name || host.ip}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{hasTags && (
|
|
||||||
<div
|
|
||||||
className="border-t border-border bg-[#18181b] flex items-center gap-1 px-2 py-2 overflow-x-auto overflow-y-hidden scrollbar-thin scrollbar-thumb-muted-foreground/30 scrollbar-track-transparent"
|
|
||||||
style={{height: 30}}>
|
|
||||||
{tags.map((tag: string) => (
|
|
||||||
<span key={tag}
|
|
||||||
className="bg-muted-foreground/10 text-xs rounded-full px-2 py-0.5 text-muted-foreground whitespace-nowrap border border-border flex-shrink-0 hover:bg-muted transition-colors">
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {Button} from "@/components/ui/button.tsx";
|
|
||||||
import {X, SeparatorVertical} from "lucide-react"
|
|
||||||
|
|
||||||
interface TerminalTab {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SSHTabListProps {
|
|
||||||
allTabs: TerminalTab[];
|
|
||||||
currentTab: number;
|
|
||||||
setActiveTab: (tab: number) => void;
|
|
||||||
allSplitScreenTab: number[];
|
|
||||||
setSplitScreenTab: (tab: number) => void;
|
|
||||||
setCloseTab: (tab: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SSHTabList({
|
|
||||||
allTabs,
|
|
||||||
currentTab,
|
|
||||||
setActiveTab,
|
|
||||||
allSplitScreenTab = [],
|
|
||||||
setSplitScreenTab,
|
|
||||||
setCloseTab,
|
|
||||||
}: SSHTabListProps): React.ReactElement {
|
|
||||||
const isSplitScreenActive = allSplitScreenTab.length > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="inline-flex items-center h-full px-[0.5rem] overflow-x-auto">
|
|
||||||
{allTabs.map((terminal, index) => {
|
|
||||||
const isActive = terminal.id === currentTab;
|
|
||||||
const isSplit = allSplitScreenTab.includes(terminal.id);
|
|
||||||
const isSplitButtonDisabled =
|
|
||||||
(isActive && !isSplitScreenActive) ||
|
|
||||||
(allSplitScreenTab.length >= 3 && !isSplit);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={terminal.id}
|
|
||||||
className={index < allTabs.length - 1 ? "mr-[0.5rem]" : ""}
|
|
||||||
>
|
|
||||||
<div className="inline-flex rounded-md shadow-sm" role="group">
|
|
||||||
<Button
|
|
||||||
onClick={() => setActiveTab(terminal.id)}
|
|
||||||
disabled={isSplit}
|
|
||||||
variant="outline"
|
|
||||||
className={`rounded-r-none ${isActive ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
|
|
||||||
>
|
|
||||||
{terminal.title}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={() => setSplitScreenTab(terminal.id)}
|
|
||||||
disabled={isSplitButtonDisabled || isActive}
|
|
||||||
variant="outline"
|
|
||||||
className="rounded-none p-0 !w-9 !h-9"
|
|
||||||
>
|
|
||||||
<SeparatorVertical className="!w-5 !h-5" strokeWidth={2.5}/>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={() => setCloseTab(terminal.id)}
|
|
||||||
disabled={(isSplitScreenActive && isActive) || isSplit}
|
|
||||||
variant="outline"
|
|
||||||
className="rounded-l-none p-0 !w-9 !h-9"
|
|
||||||
>
|
|
||||||
<X className="!w-5 !h-5" strokeWidth={2.5}/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import {SSHTabList} from "@/apps/SSH/Terminal/SSHTabList.tsx";
|
|
||||||
import React from "react";
|
|
||||||
import {ChevronUp} from "lucide-react";
|
|
||||||
|
|
||||||
interface TerminalTab {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SSHTopbarProps {
|
|
||||||
allTabs: TerminalTab[];
|
|
||||||
currentTab: number;
|
|
||||||
setActiveTab: (tab: number) => void;
|
|
||||||
allSplitScreenTab: number[];
|
|
||||||
setSplitScreenTab: (tab: number) => void;
|
|
||||||
setCloseTab: (tab: number) => void;
|
|
||||||
onHideTopbar?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SSHTopbar({
|
|
||||||
allTabs,
|
|
||||||
currentTab,
|
|
||||||
setActiveTab,
|
|
||||||
allSplitScreenTab,
|
|
||||||
setSplitScreenTab,
|
|
||||||
setCloseTab,
|
|
||||||
onHideTopbar
|
|
||||||
}: SSHTopbarProps): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="flex h-11.5 z-100" style={{
|
|
||||||
position: 'absolute',
|
|
||||||
left: 0,
|
|
||||||
width: '100%',
|
|
||||||
backgroundColor: '#18181b',
|
|
||||||
borderBottom: '1px solid #222224',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}>
|
|
||||||
<div style={{flex: 1, minWidth: 0, height: '100%', overflowX: 'auto'}}>
|
|
||||||
<div style={{minWidth: 'max-content', height: '100%', paddingLeft: 8, overflowY: 'hidden'}}>
|
|
||||||
<SSHTabList
|
|
||||||
allTabs={allTabs}
|
|
||||||
currentTab={currentTab}
|
|
||||||
setActiveTab={setActiveTab}
|
|
||||||
allSplitScreenTab={allSplitScreenTab}
|
|
||||||
setSplitScreenTab={setSplitScreenTab}
|
|
||||||
setCloseTab={setCloseTab}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{flex: '0 0 auto', paddingRight: 8, paddingLeft: 16}}>
|
|
||||||
<button
|
|
||||||
onClick={() => onHideTopbar?.()}
|
|
||||||
style={{
|
|
||||||
height: 28,
|
|
||||||
width: 28,
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
background: 'hsl(240 5% 9%)',
|
|
||||||
color: 'hsl(240 5% 64.9%)',
|
|
||||||
border: '1px solid hsl(240 3.7% 15.9%)',
|
|
||||||
borderRadius: 6,
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
title="Hide top bar"
|
|
||||||
>
|
|
||||||
<ChevronUp size={16} strokeWidth={2}/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import {
|
|
||||||
CornerDownLeft,
|
|
||||||
Settings
|
|
||||||
} from "lucide-react"
|
|
||||||
|
|
||||||
import {
|
|
||||||
Button
|
|
||||||
} from "@/components/ui/button.tsx"
|
|
||||||
|
|
||||||
import {
|
|
||||||
Sidebar,
|
|
||||||
SidebarContent,
|
|
||||||
SidebarGroup,
|
|
||||||
SidebarGroupContent,
|
|
||||||
SidebarGroupLabel,
|
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuItem, SidebarProvider,
|
|
||||||
} from "@/components/ui/sidebar.tsx"
|
|
||||||
|
|
||||||
import {
|
|
||||||
Separator,
|
|
||||||
} from "@/components/ui/separator.tsx"
|
|
||||||
|
|
||||||
interface SidebarProps {
|
|
||||||
onSelectView: (view: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SSHTunnelSidebar({onSelectView}: SidebarProps): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<SidebarProvider>
|
|
||||||
<Sidebar>
|
|
||||||
<SidebarContent>
|
|
||||||
<SidebarGroup>
|
|
||||||
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
|
|
||||||
Termix / Tunnel
|
|
||||||
</SidebarGroupLabel>
|
|
||||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
|
||||||
<SidebarGroupContent className="flex flex-col flex-grow">
|
|
||||||
<SidebarMenu>
|
|
||||||
|
|
||||||
<SidebarMenuItem key={"Homepage"}>
|
|
||||||
<Button className="w-full mt-2 mb-2 h-8" onClick={() => onSelectView("homepage")}
|
|
||||||
variant="outline">
|
|
||||||
<CornerDownLeft className="h-4 w-4 mr-2"/>
|
|
||||||
Return
|
|
||||||
</Button>
|
|
||||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroupContent>
|
|
||||||
</SidebarGroup>
|
|
||||||
</SidebarContent>
|
|
||||||
</Sidebar>
|
|
||||||
</SidebarProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {SSHTunnelObject} from "./SSHTunnelObject.tsx";
|
|
||||||
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion.tsx";
|
|
||||||
import {Separator} from "@/components/ui/separator.tsx";
|
|
||||||
import {Input} from "@/components/ui/input.tsx";
|
|
||||||
import {Search} from "lucide-react";
|
|
||||||
|
|
||||||
interface TunnelConnection {
|
|
||||||
sourcePort: number;
|
|
||||||
endpointPort: number;
|
|
||||||
endpointHost: string;
|
|
||||||
maxRetries: number;
|
|
||||||
retryInterval: number;
|
|
||||||
autoStart: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SSHHost {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
ip: string;
|
|
||||||
port: number;
|
|
||||||
username: string;
|
|
||||||
folder: string;
|
|
||||||
tags: string[];
|
|
||||||
pin: boolean;
|
|
||||||
authType: string;
|
|
||||||
enableTerminal: boolean;
|
|
||||||
enableTunnel: boolean;
|
|
||||||
enableConfigEditor: boolean;
|
|
||||||
defaultPath: string;
|
|
||||||
tunnelConnections: TunnelConnection[];
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TunnelStatus {
|
|
||||||
status: string;
|
|
||||||
reason?: string;
|
|
||||||
errorType?: string;
|
|
||||||
retryCount?: number;
|
|
||||||
maxRetries?: number;
|
|
||||||
nextRetryIn?: number;
|
|
||||||
retryExhausted?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SSHTunnelViewerProps {
|
|
||||||
hosts: SSHHost[];
|
|
||||||
tunnelStatuses: Record<string, TunnelStatus>;
|
|
||||||
tunnelActions: Record<string, boolean>;
|
|
||||||
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SSHTunnelViewer({
|
|
||||||
hosts = [],
|
|
||||||
tunnelStatuses = {},
|
|
||||||
tunnelActions = {},
|
|
||||||
onTunnelAction
|
|
||||||
}: SSHTunnelViewerProps): React.ReactElement {
|
|
||||||
const [searchQuery, setSearchQuery] = React.useState("");
|
|
||||||
const [debouncedSearch, setDebouncedSearch] = React.useState("");
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const handler = setTimeout(() => setDebouncedSearch(searchQuery), 200);
|
|
||||||
return () => clearTimeout(handler);
|
|
||||||
}, [searchQuery]);
|
|
||||||
|
|
||||||
const filteredHosts = React.useMemo(() => {
|
|
||||||
if (!debouncedSearch.trim()) return hosts;
|
|
||||||
|
|
||||||
const query = debouncedSearch.trim().toLowerCase();
|
|
||||||
return hosts.filter(host => {
|
|
||||||
const searchableText = [
|
|
||||||
host.name || '',
|
|
||||||
host.username,
|
|
||||||
host.ip,
|
|
||||||
host.folder || '',
|
|
||||||
...(host.tags || []),
|
|
||||||
host.authType,
|
|
||||||
host.defaultPath || ''
|
|
||||||
].join(' ').toLowerCase();
|
|
||||||
return searchableText.includes(query);
|
|
||||||
});
|
|
||||||
}, [hosts, debouncedSearch]);
|
|
||||||
|
|
||||||
const tunnelHosts = React.useMemo(() => {
|
|
||||||
return filteredHosts.filter(host =>
|
|
||||||
host.enableTunnel &&
|
|
||||||
host.tunnelConnections &&
|
|
||||||
host.tunnelConnections.length > 0
|
|
||||||
);
|
|
||||||
}, [filteredHosts]);
|
|
||||||
|
|
||||||
const hostsByFolder = React.useMemo(() => {
|
|
||||||
const map: Record<string, SSHHost[]> = {};
|
|
||||||
tunnelHosts.forEach(host => {
|
|
||||||
const folder = host.folder && host.folder.trim() ? host.folder : 'Uncategorized';
|
|
||||||
if (!map[folder]) map[folder] = [];
|
|
||||||
map[folder].push(host);
|
|
||||||
});
|
|
||||||
return map;
|
|
||||||
}, [tunnelHosts]);
|
|
||||||
|
|
||||||
const sortedFolders = React.useMemo(() => {
|
|
||||||
const folders = Object.keys(hostsByFolder);
|
|
||||||
folders.sort((a, b) => {
|
|
||||||
if (a === 'Uncategorized') return -1;
|
|
||||||
if (b === 'Uncategorized') return 1;
|
|
||||||
return a.localeCompare(b);
|
|
||||||
});
|
|
||||||
return folders;
|
|
||||||
}, [hostsByFolder]);
|
|
||||||
|
|
||||||
const getSortedHosts = (arr: SSHHost[]) => {
|
|
||||||
const pinned = arr.filter(h => h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
|
||||||
const rest = arr.filter(h => !h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
|
||||||
return [...pinned, ...rest];
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full p-6" style={{width: 'calc(100vw - 256px)', maxWidth: 'none'}}>
|
|
||||||
<div className="w-full min-w-0" style={{width: '100%', maxWidth: 'none'}}>
|
|
||||||
<div className="mb-6">
|
|
||||||
<h1 className="text-2xl font-bold text-foreground mb-2">
|
|
||||||
SSH Tunnels
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Manage your SSH tunnel connections
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative mb-3">
|
|
||||||
<Search
|
|
||||||
className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
|
|
||||||
<Input
|
|
||||||
placeholder="Search hosts by name, username, IP, folder, tags..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{tunnelHosts.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
|
||||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
|
||||||
No SSH Tunnels
|
|
||||||
</h3>
|
|
||||||
<p className="text-muted-foreground max-w-md">
|
|
||||||
{searchQuery.trim() ?
|
|
||||||
"No hosts match your search criteria." :
|
|
||||||
"Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel connections."
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Accordion type="multiple" className="w-full" defaultValue={sortedFolders}>
|
|
||||||
{sortedFolders.map((folder, idx) => (
|
|
||||||
<AccordionItem value={folder} key={`folder-${folder}`}
|
|
||||||
className={idx === 0 ? "mt-0" : "mt-2"}>
|
|
||||||
<AccordionTrigger className="text-base font-semibold rounded-t-none px-3 py-2"
|
|
||||||
style={{marginTop: idx === 0 ? 0 : undefined}}>
|
|
||||||
{folder}
|
|
||||||
</AccordionTrigger>
|
|
||||||
<AccordionContent className="flex flex-col gap-1 px-3 pb-2 pt-1">
|
|
||||||
<div className="grid grid-cols-4 gap-6 w-full">
|
|
||||||
{getSortedHosts(hostsByFolder[folder]).map((host, hostIndex) => (
|
|
||||||
<div key={host.id} className="w-full">
|
|
||||||
<SSHTunnelObject
|
|
||||||
host={host}
|
|
||||||
tunnelStatuses={tunnelStatuses}
|
|
||||||
tunnelActions={tunnelActions}
|
|
||||||
onTunnelAction={onTunnelAction}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
))}
|
|
||||||
</Accordion>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,520 +0,0 @@
|
|||||||
import axios from 'axios';
|
|
||||||
|
|
||||||
interface SSHHostData {
|
|
||||||
name?: string;
|
|
||||||
ip: string;
|
|
||||||
port: number;
|
|
||||||
username: string;
|
|
||||||
folder?: string;
|
|
||||||
tags?: string[];
|
|
||||||
pin?: boolean;
|
|
||||||
authType: 'password' | 'key';
|
|
||||||
password?: string;
|
|
||||||
key?: File | null;
|
|
||||||
keyPassword?: string;
|
|
||||||
keyType?: string;
|
|
||||||
enableTerminal?: boolean;
|
|
||||||
enableTunnel?: boolean;
|
|
||||||
enableConfigEditor?: boolean;
|
|
||||||
defaultPath?: string;
|
|
||||||
tunnelConnections?: any[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SSHHost {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
ip: string;
|
|
||||||
port: number;
|
|
||||||
username: string;
|
|
||||||
folder: string;
|
|
||||||
tags: string[];
|
|
||||||
pin: boolean;
|
|
||||||
authType: string;
|
|
||||||
password?: string;
|
|
||||||
key?: string;
|
|
||||||
keyPassword?: string;
|
|
||||||
keyType?: string;
|
|
||||||
enableTerminal: boolean;
|
|
||||||
enableTunnel: boolean;
|
|
||||||
enableConfigEditor: boolean;
|
|
||||||
defaultPath: string;
|
|
||||||
tunnelConnections: any[];
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TunnelConfig {
|
|
||||||
name: string;
|
|
||||||
hostName: string;
|
|
||||||
sourceIP: string;
|
|
||||||
sourceSSHPort: number;
|
|
||||||
sourceUsername: string;
|
|
||||||
sourcePassword?: string;
|
|
||||||
sourceAuthMethod: string;
|
|
||||||
sourceSSHKey?: string;
|
|
||||||
sourceKeyPassword?: string;
|
|
||||||
sourceKeyType?: string;
|
|
||||||
endpointIP: string;
|
|
||||||
endpointSSHPort: number;
|
|
||||||
endpointUsername: string;
|
|
||||||
endpointPassword?: string;
|
|
||||||
endpointAuthMethod: string;
|
|
||||||
endpointSSHKey?: string;
|
|
||||||
endpointKeyPassword?: string;
|
|
||||||
endpointKeyType?: string;
|
|
||||||
sourcePort: number;
|
|
||||||
endpointPort: number;
|
|
||||||
maxRetries: number;
|
|
||||||
retryInterval: number;
|
|
||||||
autoStart: boolean;
|
|
||||||
isPinned: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TunnelStatus {
|
|
||||||
status: string;
|
|
||||||
reason?: string;
|
|
||||||
errorType?: string;
|
|
||||||
retryCount?: number;
|
|
||||||
maxRetries?: number;
|
|
||||||
nextRetryIn?: number;
|
|
||||||
retryExhausted?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ConfigEditorFile {
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
type?: 'file' | 'directory';
|
|
||||||
isSSH?: boolean;
|
|
||||||
sshSessionId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ConfigEditorShortcut {
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
|
||||||
|
|
||||||
const sshHostApi = axios.create({
|
|
||||||
baseURL: isLocalhost ? 'http://localhost:8081' : window.location.origin,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const tunnelApi = axios.create({
|
|
||||||
baseURL: isLocalhost ? 'http://localhost:8083' : window.location.origin,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const configEditorApi = axios.create({
|
|
||||||
baseURL: isLocalhost ? 'http://localhost:8084' : window.location.origin,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function getCookie(name: string): string | undefined {
|
|
||||||
const value = `; ${document.cookie}`;
|
|
||||||
const parts = value.split(`; ${name}=`);
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
|
|
||||||
tunnelApi.interceptors.request.use((config) => {
|
|
||||||
const token = getCookie('jwt');
|
|
||||||
if (token) {
|
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
return config;
|
|
||||||
});
|
|
||||||
|
|
||||||
configEditorApi.interceptors.request.use((config) => {
|
|
||||||
const token = getCookie('jwt');
|
|
||||||
if (token) {
|
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
return config;
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function getSSHHosts(): Promise<SSHHost[]> {
|
|
||||||
try {
|
|
||||||
const response = await sshHostApi.get('/ssh/db/host');
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
|
|
||||||
try {
|
|
||||||
const submitData = {
|
|
||||||
name: hostData.name || '',
|
|
||||||
ip: hostData.ip,
|
|
||||||
port: parseInt(hostData.port.toString()) || 22,
|
|
||||||
username: hostData.username,
|
|
||||||
folder: hostData.folder || '',
|
|
||||||
tags: hostData.tags || [],
|
|
||||||
pin: hostData.pin || false,
|
|
||||||
authMethod: hostData.authType,
|
|
||||||
password: hostData.authType === 'password' ? hostData.password : '',
|
|
||||||
key: hostData.authType === 'key' ? hostData.key : null,
|
|
||||||
keyPassword: hostData.authType === 'key' ? hostData.keyPassword : '',
|
|
||||||
keyType: hostData.authType === 'key' ? hostData.keyType : '',
|
|
||||||
enableTerminal: hostData.enableTerminal !== false,
|
|
||||||
enableTunnel: hostData.enableTunnel !== false,
|
|
||||||
enableConfigEditor: hostData.enableConfigEditor !== false,
|
|
||||||
defaultPath: hostData.defaultPath || '/',
|
|
||||||
tunnelConnections: hostData.tunnelConnections || [],
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!submitData.enableTunnel) {
|
|
||||||
submitData.tunnelConnections = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!submitData.enableConfigEditor) {
|
|
||||||
submitData.defaultPath = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hostData.authType === 'key' && hostData.key instanceof File) {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('key', hostData.key);
|
|
||||||
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.data;
|
|
||||||
} else {
|
|
||||||
const response = await sshHostApi.post('/ssh/db/host', submitData);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateSSHHost(hostId: number, hostData: SSHHostData): Promise<SSHHost> {
|
|
||||||
try {
|
|
||||||
const submitData = {
|
|
||||||
name: hostData.name || '',
|
|
||||||
ip: hostData.ip,
|
|
||||||
port: parseInt(hostData.port.toString()) || 22,
|
|
||||||
username: hostData.username,
|
|
||||||
folder: hostData.folder || '',
|
|
||||||
tags: hostData.tags || [],
|
|
||||||
pin: hostData.pin || false,
|
|
||||||
authMethod: hostData.authType,
|
|
||||||
password: hostData.authType === 'password' ? hostData.password : '',
|
|
||||||
key: hostData.authType === 'key' ? hostData.key : null,
|
|
||||||
keyPassword: hostData.authType === 'key' ? hostData.keyPassword : '',
|
|
||||||
keyType: hostData.authType === 'key' ? hostData.keyType : '',
|
|
||||||
enableTerminal: hostData.enableTerminal !== false,
|
|
||||||
enableTunnel: hostData.enableTunnel !== false,
|
|
||||||
enableConfigEditor: hostData.enableConfigEditor !== false,
|
|
||||||
defaultPath: hostData.defaultPath || '/',
|
|
||||||
tunnelConnections: hostData.tunnelConnections || [],
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!submitData.enableTunnel) {
|
|
||||||
submitData.tunnelConnections = [];
|
|
||||||
}
|
|
||||||
if (!submitData.enableConfigEditor) {
|
|
||||||
submitData.defaultPath = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hostData.authType === 'key' && hostData.key instanceof File) {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('key', hostData.key);
|
|
||||||
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.data;
|
|
||||||
} else {
|
|
||||||
const response = await sshHostApi.put(`/ssh/db/host/${hostId}`, submitData);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteSSHHost(hostId: number): Promise<any> {
|
|
||||||
try {
|
|
||||||
const response = await sshHostApi.delete(`/ssh/db/host/${hostId}`);
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSSHHostById(hostId: number): Promise<SSHHost> {
|
|
||||||
try {
|
|
||||||
const response = await sshHostApi.get(`/ssh/db/host/${hostId}`);
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getTunnelStatuses(): Promise<Record<string, TunnelStatus>> {
|
|
||||||
try {
|
|
||||||
const response = await tunnelApi.get('/ssh/tunnel/status');
|
|
||||||
return response.data || {};
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getTunnelStatusByName(tunnelName: string): Promise<TunnelStatus | undefined> {
|
|
||||||
const statuses = await getTunnelStatuses();
|
|
||||||
return statuses[tunnelName];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function connectTunnel(tunnelConfig: TunnelConfig): Promise<any> {
|
|
||||||
try {
|
|
||||||
const response = await tunnelApi.post('/ssh/tunnel/connect', tunnelConfig);
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function disconnectTunnel(tunnelName: string): Promise<any> {
|
|
||||||
try {
|
|
||||||
const response = await tunnelApi.post('/ssh/tunnel/disconnect', {tunnelName});
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cancelTunnel(tunnelName: string): Promise<any> {
|
|
||||||
try {
|
|
||||||
const response = await tunnelApi.post('/ssh/tunnel/cancel', {tunnelName});
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getConfigEditorRecent(hostId: number): Promise<ConfigEditorFile[]> {
|
|
||||||
try {
|
|
||||||
const response = await sshHostApi.get(`/ssh/config_editor/recent?hostId=${hostId}`);
|
|
||||||
return response.data || [];
|
|
||||||
} catch (error) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function addConfigEditorRecent(file: {
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
isSSH: boolean;
|
|
||||||
sshSessionId?: string;
|
|
||||||
hostId: number
|
|
||||||
}): Promise<any> {
|
|
||||||
try {
|
|
||||||
const response = await sshHostApi.post('/ssh/config_editor/recent', file);
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function removeConfigEditorRecent(file: {
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
isSSH: boolean;
|
|
||||||
sshSessionId?: string;
|
|
||||||
hostId: number
|
|
||||||
}): Promise<any> {
|
|
||||||
try {
|
|
||||||
const response = await sshHostApi.delete('/ssh/config_editor/recent', {data: file});
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getConfigEditorPinned(hostId: number): Promise<ConfigEditorFile[]> {
|
|
||||||
try {
|
|
||||||
const response = await sshHostApi.get(`/ssh/config_editor/pinned?hostId=${hostId}`);
|
|
||||||
return response.data || [];
|
|
||||||
} catch (error) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function addConfigEditorPinned(file: {
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
isSSH: boolean;
|
|
||||||
sshSessionId?: string;
|
|
||||||
hostId: number
|
|
||||||
}): Promise<any> {
|
|
||||||
try {
|
|
||||||
const response = await sshHostApi.post('/ssh/config_editor/pinned', file);
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function removeConfigEditorPinned(file: {
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
isSSH: boolean;
|
|
||||||
sshSessionId?: string;
|
|
||||||
hostId: number
|
|
||||||
}): Promise<any> {
|
|
||||||
try {
|
|
||||||
const response = await sshHostApi.delete('/ssh/config_editor/pinned', {data: file});
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getConfigEditorShortcuts(hostId: number): Promise<ConfigEditorShortcut[]> {
|
|
||||||
try {
|
|
||||||
const response = await sshHostApi.get(`/ssh/config_editor/shortcuts?hostId=${hostId}`);
|
|
||||||
return response.data || [];
|
|
||||||
} catch (error) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function addConfigEditorShortcut(shortcut: {
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
isSSH: boolean;
|
|
||||||
sshSessionId?: string;
|
|
||||||
hostId: number
|
|
||||||
}): Promise<any> {
|
|
||||||
try {
|
|
||||||
const response = await sshHostApi.post('/ssh/config_editor/shortcuts', shortcut);
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function removeConfigEditorShortcut(shortcut: {
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
isSSH: boolean;
|
|
||||||
sshSessionId?: string;
|
|
||||||
hostId: number
|
|
||||||
}): Promise<any> {
|
|
||||||
try {
|
|
||||||
const response = await sshHostApi.delete('/ssh/config_editor/shortcuts', {data: shortcut});
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function connectSSH(sessionId: string, config: {
|
|
||||||
ip: string;
|
|
||||||
port: number;
|
|
||||||
username: string;
|
|
||||||
password?: string;
|
|
||||||
sshKey?: string;
|
|
||||||
keyPassword?: string;
|
|
||||||
}): Promise<any> {
|
|
||||||
try {
|
|
||||||
const response = await configEditorApi.post('/ssh/config_editor/ssh/connect', {
|
|
||||||
sessionId,
|
|
||||||
...config
|
|
||||||
});
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function disconnectSSH(sessionId: string): Promise<any> {
|
|
||||||
try {
|
|
||||||
const response = await configEditorApi.post('/ssh/config_editor/ssh/disconnect', {sessionId});
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSSHStatus(sessionId: string): Promise<{ connected: boolean }> {
|
|
||||||
try {
|
|
||||||
const response = await configEditorApi.get('/ssh/config_editor/ssh/status', {
|
|
||||||
params: {sessionId}
|
|
||||||
});
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listSSHFiles(sessionId: string, path: string): Promise<any[]> {
|
|
||||||
try {
|
|
||||||
const response = await configEditorApi.get('/ssh/config_editor/ssh/listFiles', {
|
|
||||||
params: {sessionId, path}
|
|
||||||
});
|
|
||||||
return response.data || [];
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function readSSHFile(sessionId: string, path: string): Promise<{ content: string; path: string }> {
|
|
||||||
try {
|
|
||||||
const response = await configEditorApi.get('/ssh/config_editor/ssh/readFile', {
|
|
||||||
params: {sessionId, path}
|
|
||||||
});
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function writeSSHFile(sessionId: string, path: string, content: string): Promise<any> {
|
|
||||||
try {
|
|
||||||
const response = await configEditorApi.post('/ssh/config_editor/ssh/writeFile', {
|
|
||||||
sessionId,
|
|
||||||
path,
|
|
||||||
content
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.data && (response.data.message === 'File written successfully' || response.status === 200)) {
|
|
||||||
return response.data;
|
|
||||||
} else {
|
|
||||||
throw new Error('File write operation did not return success status');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export {sshHostApi, tunnelApi, configEditorApi};
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {TemplateSidebar} from "@/apps/Template/TemplateSidebar.tsx";
|
|
||||||
|
|
||||||
interface ConfigEditorProps {
|
|
||||||
onSelectView: (view: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Template({onSelectView}: ConfigEditorProps): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<TemplateSidebar
|
|
||||||
onSelectView={onSelectView}
|
|
||||||
/>
|
|
||||||
|
|
||||||
Template
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import {
|
|
||||||
CornerDownLeft
|
|
||||||
} from "lucide-react"
|
|
||||||
|
|
||||||
import {
|
|
||||||
Button
|
|
||||||
} from "@/components/ui/button.tsx"
|
|
||||||
|
|
||||||
import {
|
|
||||||
Sidebar,
|
|
||||||
SidebarContent,
|
|
||||||
SidebarGroup,
|
|
||||||
SidebarGroupContent,
|
|
||||||
SidebarGroupLabel,
|
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuItem, SidebarProvider,
|
|
||||||
} from "@/components/ui/sidebar.tsx"
|
|
||||||
|
|
||||||
import {
|
|
||||||
Separator,
|
|
||||||
} from "@/components/ui/separator.tsx"
|
|
||||||
|
|
||||||
interface SidebarProps {
|
|
||||||
onSelectView: (view: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TemplateSidebar({onSelectView}: SidebarProps): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<SidebarProvider>
|
|
||||||
<Sidebar>
|
|
||||||
<SidebarContent>
|
|
||||||
<SidebarGroup>
|
|
||||||
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
|
|
||||||
Termix / Template
|
|
||||||
</SidebarGroupLabel>
|
|
||||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
|
||||||
<SidebarGroupContent className="flex flex-col flex-grow">
|
|
||||||
<SidebarMenu>
|
|
||||||
|
|
||||||
<SidebarMenuItem key={"Homepage"}>
|
|
||||||
<Button className="w-full mt-2 mb-2 h-8" onClick={() => onSelectView("homepage")}
|
|
||||||
variant="outline">
|
|
||||||
<CornerDownLeft/>
|
|
||||||
Return
|
|
||||||
</Button>
|
|
||||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroupContent>
|
|
||||||
</SidebarGroup>
|
|
||||||
</SidebarContent>
|
|
||||||
</Sidebar>
|
|
||||||
</SidebarProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,439 +0,0 @@
|
|||||||
import express from 'express';
|
|
||||||
import cors from 'cors';
|
|
||||||
import {Client as SSHClient} from 'ssh2';
|
|
||||||
import chalk from "chalk";
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
|
|
||||||
app.use(cors({
|
|
||||||
origin: '*',
|
|
||||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
|
||||||
allowedHeaders: ['Content-Type', 'Authorization']
|
|
||||||
}));
|
|
||||||
app.use(express.json());
|
|
||||||
|
|
||||||
const sshIconSymbol = '📁';
|
|
||||||
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
|
||||||
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
|
||||||
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${sshIconSymbol}]`)} ${message}`;
|
|
||||||
};
|
|
||||||
const logger = {
|
|
||||||
info: (msg: string): void => {
|
|
||||||
console.log(formatMessage('info', chalk.cyan, msg));
|
|
||||||
},
|
|
||||||
warn: (msg: string): void => {
|
|
||||||
console.warn(formatMessage('warn', chalk.yellow, msg));
|
|
||||||
},
|
|
||||||
error: (msg: string, err?: unknown): void => {
|
|
||||||
console.error(formatMessage('error', chalk.redBright, msg));
|
|
||||||
if (err) console.error(err);
|
|
||||||
},
|
|
||||||
success: (msg: string): void => {
|
|
||||||
console.log(formatMessage('success', chalk.greenBright, msg));
|
|
||||||
},
|
|
||||||
debug: (msg: string): void => {
|
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
|
||||||
console.debug(formatMessage('debug', chalk.magenta, msg));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
interface SSHSession {
|
|
||||||
client: SSHClient;
|
|
||||||
isConnected: boolean;
|
|
||||||
lastActive: number;
|
|
||||||
timeout?: NodeJS.Timeout;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sshSessions: Record<string, SSHSession> = {};
|
|
||||||
const SESSION_TIMEOUT_MS = 10 * 60 * 1000;
|
|
||||||
|
|
||||||
function cleanupSession(sessionId: string) {
|
|
||||||
const session = sshSessions[sessionId];
|
|
||||||
if (session) {
|
|
||||||
try {
|
|
||||||
session.client.end();
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
clearTimeout(session.timeout);
|
|
||||||
delete sshSessions[sessionId];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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/config_editor/ssh/connect', (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);
|
|
||||||
const client = new SSHClient();
|
|
||||||
const config: any = {
|
|
||||||
host: ip,
|
|
||||||
port: port || 22,
|
|
||||||
username,
|
|
||||||
readyTimeout: 20000,
|
|
||||||
keepaliveInterval: 10000,
|
|
||||||
keepaliveCountMax: 3,
|
|
||||||
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 (sshKey && sshKey.trim()) {
|
|
||||||
config.privateKey = sshKey;
|
|
||||||
if (keyPassword) config.passphrase = keyPassword;
|
|
||||||
} else if (password && password.trim()) {
|
|
||||||
config.password = password;
|
|
||||||
} else {
|
|
||||||
return res.status(400).json({error: 'Either password or SSH key must be provided'});
|
|
||||||
}
|
|
||||||
|
|
||||||
let responseSent = false;
|
|
||||||
|
|
||||||
client.on('ready', () => {
|
|
||||||
if (responseSent) return;
|
|
||||||
responseSent = true;
|
|
||||||
sshSessions[sessionId] = {client, isConnected: true, lastActive: Date.now()};
|
|
||||||
scheduleSessionCleanup(sessionId);
|
|
||||||
res.json({status: 'success', message: 'SSH connection established'});
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on('error', (err) => {
|
|
||||||
if (responseSent) return;
|
|
||||||
responseSent = true;
|
|
||||||
logger.error(`SSH connection error for session ${sessionId}:`, err.message);
|
|
||||||
res.status(500).json({status: 'error', message: err.message});
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on('close', () => {
|
|
||||||
if (sshSessions[sessionId]) sshSessions[sessionId].isConnected = false;
|
|
||||||
cleanupSession(sessionId);
|
|
||||||
});
|
|
||||||
|
|
||||||
client.connect(config);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post('/ssh/config_editor/ssh/disconnect', (req, res) => {
|
|
||||||
const {sessionId} = req.body;
|
|
||||||
cleanupSession(sessionId);
|
|
||||||
res.json({status: 'success', message: 'SSH connection disconnected'});
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/ssh/config_editor/ssh/status', (req, res) => {
|
|
||||||
const sessionId = req.query.sessionId as string;
|
|
||||||
const isConnected = !!sshSessions[sessionId]?.isConnected;
|
|
||||||
res.json({status: 'success', connected: isConnected});
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/ssh/config_editor/ssh/listFiles', (req, res) => {
|
|
||||||
const sessionId = req.query.sessionId as string;
|
|
||||||
const sshConn = sshSessions[sessionId];
|
|
||||||
const sshPath = decodeURIComponent((req.query.path as string) || '/');
|
|
||||||
|
|
||||||
if (!sessionId) {
|
|
||||||
return res.status(400).json({error: 'Session ID is required'});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sshConn?.isConnected) {
|
|
||||||
return res.status(400).json({error: 'SSH connection not established'});
|
|
||||||
}
|
|
||||||
|
|
||||||
sshConn.lastActive = Date.now();
|
|
||||||
scheduleSessionCleanup(sessionId);
|
|
||||||
|
|
||||||
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
|
|
||||||
sshConn.client.exec(`ls -la '${escapedPath}'`, (err, stream) => {
|
|
||||||
if (err) {
|
|
||||||
logger.error('SSH listFiles error:', err);
|
|
||||||
return res.status(500).json({error: err.message});
|
|
||||||
}
|
|
||||||
|
|
||||||
let data = '';
|
|
||||||
let errorData = '';
|
|
||||||
|
|
||||||
stream.on('data', (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.stderr.on('data', (chunk: Buffer) => {
|
|
||||||
errorData += chunk.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on('close', (code) => {
|
|
||||||
if (code !== 0) {
|
|
||||||
logger.error(`SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
|
|
||||||
return res.status(500).json({error: `Command failed: ${errorData}`});
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = data.split('\n').filter(line => line.trim());
|
|
||||||
const files = [];
|
|
||||||
|
|
||||||
for (let i = 1; i < lines.length; i++) {
|
|
||||||
const line = lines[i];
|
|
||||||
const parts = line.split(/\s+/);
|
|
||||||
if (parts.length >= 9) {
|
|
||||||
const permissions = parts[0];
|
|
||||||
const name = parts.slice(8).join(' ');
|
|
||||||
const isDirectory = permissions.startsWith('d');
|
|
||||||
const isLink = permissions.startsWith('l');
|
|
||||||
|
|
||||||
if (name === '.' || name === '..') continue;
|
|
||||||
|
|
||||||
files.push({
|
|
||||||
name,
|
|
||||||
type: isDirectory ? 'directory' : (isLink ? 'link' : 'file')
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(files);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/ssh/config_editor/ssh/readFile', (req, res) => {
|
|
||||||
const sessionId = req.query.sessionId as string;
|
|
||||||
const sshConn = sshSessions[sessionId];
|
|
||||||
const filePath = decodeURIComponent(req.query.path as string);
|
|
||||||
|
|
||||||
if (!sessionId) {
|
|
||||||
return res.status(400).json({error: 'Session ID is required'});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sshConn?.isConnected) {
|
|
||||||
return res.status(400).json({error: 'SSH connection not established'});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!filePath) {
|
|
||||||
return res.status(400).json({error: 'File path is required'});
|
|
||||||
}
|
|
||||||
|
|
||||||
sshConn.lastActive = Date.now();
|
|
||||||
scheduleSessionCleanup(sessionId);
|
|
||||||
|
|
||||||
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
|
|
||||||
sshConn.client.exec(`cat '${escapedPath}'`, (err, stream) => {
|
|
||||||
if (err) {
|
|
||||||
logger.error('SSH readFile error:', err);
|
|
||||||
return res.status(500).json({error: err.message});
|
|
||||||
}
|
|
||||||
|
|
||||||
let data = '';
|
|
||||||
let errorData = '';
|
|
||||||
|
|
||||||
stream.on('data', (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.stderr.on('data', (chunk: Buffer) => {
|
|
||||||
errorData += chunk.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on('close', (code) => {
|
|
||||||
if (code !== 0) {
|
|
||||||
logger.error(`SSH readFile command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
|
|
||||||
return res.status(500).json({error: `Command failed: ${errorData}`});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({content: data, path: filePath});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post('/ssh/config_editor/ssh/writeFile', (req, res) => {
|
|
||||||
const {sessionId, path: filePath, content} = req.body;
|
|
||||||
const sshConn = sshSessions[sessionId];
|
|
||||||
|
|
||||||
if (!sessionId) {
|
|
||||||
return res.status(400).json({error: 'Session ID is required'});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sshConn?.isConnected) {
|
|
||||||
return res.status(400).json({error: 'SSH connection not established'});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!filePath) {
|
|
||||||
return res.status(400).json({error: 'File path is required'});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (content === undefined) {
|
|
||||||
return res.status(400).json({error: 'File content is required'});
|
|
||||||
}
|
|
||||||
|
|
||||||
sshConn.lastActive = Date.now();
|
|
||||||
scheduleSessionCleanup(sessionId);
|
|
||||||
|
|
||||||
const tempFile = `/tmp/temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
||||||
const escapedTempFile = tempFile.replace(/'/g, "'\"'\"'");
|
|
||||||
const escapedFilePath = filePath.replace(/'/g, "'\"'\"'");
|
|
||||||
|
|
||||||
const base64Content = Buffer.from(content, 'utf8').toString('base64');
|
|
||||||
|
|
||||||
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'});
|
|
||||||
}
|
|
||||||
}, 15000);
|
|
||||||
|
|
||||||
const checkCommand = `ls -la '${escapedFilePath}' 2>/dev/null || echo "File does not exist"`;
|
|
||||||
|
|
||||||
sshConn.client.exec(checkCommand, (checkErr, checkStream) => {
|
|
||||||
if (checkErr) {
|
|
||||||
return res.status(500).json({error: `File check failed: ${checkErr.message}`});
|
|
||||||
}
|
|
||||||
|
|
||||||
let checkResult = '';
|
|
||||||
checkStream.on('data', (chunk: Buffer) => {
|
|
||||||
checkResult += chunk.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
checkStream.on('close', (checkCode) => {
|
|
||||||
const writeCommand = `echo '${base64Content}' > '${escapedTempFile}' && base64 -d '${escapedTempFile}' > '${escapedFilePath}' && rm -f '${escapedTempFile}' && echo "SUCCESS" && exit 0`;
|
|
||||||
|
|
||||||
sshConn.client.exec(writeCommand, (err, stream) => {
|
|
||||||
if (err) {
|
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error('SSH writeFile error:', err);
|
|
||||||
if (!res.headersSent) {
|
|
||||||
return res.status(500).json({error: err.message});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let outputData = '';
|
|
||||||
let errorData = '';
|
|
||||||
|
|
||||||
stream.on('data', (chunk: Buffer) => {
|
|
||||||
outputData += chunk.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.stderr.on('data', (chunk: Buffer) => {
|
|
||||||
errorData += chunk.toString();
|
|
||||||
|
|
||||||
if (chunk.toString().includes('Permission denied')) {
|
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error(`Permission denied writing to file: ${filePath}`);
|
|
||||||
if (!res.headersSent) {
|
|
||||||
return res.status(403).json({
|
|
||||||
error: `Permission denied: Cannot write to ${filePath}. Check file ownership and permissions. Use 'ls -la ${filePath}' to verify.`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on('close', (code) => {
|
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
|
|
||||||
if (outputData.includes('SUCCESS')) {
|
|
||||||
const verifyCommand = `ls -la '${escapedFilePath}' 2>/dev/null | awk '{print $5}'`;
|
|
||||||
|
|
||||||
sshConn.client.exec(verifyCommand, (verifyErr, verifyStream) => {
|
|
||||||
if (verifyErr) {
|
|
||||||
if (!res.headersSent) {
|
|
||||||
res.json({message: 'File written successfully', path: filePath});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let verifyResult = '';
|
|
||||||
verifyStream.on('data', (chunk: Buffer) => {
|
|
||||||
verifyResult += chunk.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
verifyStream.on('close', (verifyCode) => {
|
|
||||||
const fileSize = Number(verifyResult.trim());
|
|
||||||
|
|
||||||
if (fileSize > 0) {
|
|
||||||
if (!res.headersSent) {
|
|
||||||
res.json({message: 'File written successfully', path: filePath});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (!res.headersSent) {
|
|
||||||
res.status(500).json({error: 'File write operation may have failed - file appears empty'});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (code !== 0) {
|
|
||||||
logger.error(`SSH writeFile command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
|
|
||||||
if (!res.headersSent) {
|
|
||||||
return res.status(500).json({error: `Command failed: ${errorData}`});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!res.headersSent) {
|
|
||||||
res.json({message: 'File written successfully', path: filePath});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on('error', (streamErr) => {
|
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error('SSH writeFile stream error:', streamErr);
|
|
||||||
if (!res.headersSent) {
|
|
||||||
res.status(500).json({error: `Stream error: ${streamErr.message}`});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on('SIGINT', () => {
|
|
||||||
Object.keys(sshSessions).forEach(cleanupSession);
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on('SIGTERM', () => {
|
|
||||||
Object.keys(sshSessions).forEach(cleanupSession);
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
const PORT = 8084;
|
|
||||||
app.listen(PORT, () => {
|
|
||||||
});
|
|
||||||
@@ -2,6 +2,7 @@ import express from 'express';
|
|||||||
import bodyParser from 'body-parser';
|
import bodyParser from 'body-parser';
|
||||||
import userRoutes from './routes/users.js';
|
import userRoutes from './routes/users.js';
|
||||||
import sshRoutes from './routes/ssh.js';
|
import sshRoutes from './routes/ssh.js';
|
||||||
|
import alertRoutes from './routes/alerts.js';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
@@ -227,12 +228,16 @@ app.get('/releases/rss', async (req, res) => {
|
|||||||
res.json(response);
|
res.json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to generate RSS format', error)
|
logger.error('Failed to generate RSS format', error)
|
||||||
res.status(500).json({ error: 'Failed to generate RSS format', details: error instanceof Error ? error.message : 'Unknown error' });
|
res.status(500).json({
|
||||||
|
error: 'Failed to generate RSS format',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use('/users', userRoutes);
|
app.use('/users', userRoutes);
|
||||||
app.use('/ssh', sshRoutes);
|
app.use('/ssh', sshRoutes);
|
||||||
|
app.use('/alerts', alertRoutes);
|
||||||
|
|
||||||
app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
logger.error('Unhandled error:', err);
|
logger.error('Unhandled error:', err);
|
||||||
@@ -240,4 +245,5 @@ app.use((err: unknown, req: express.Request, res: express.Response, next: expres
|
|||||||
});
|
});
|
||||||
|
|
||||||
const PORT = 8081;
|
const PORT = 8081;
|
||||||
app.listen(PORT, () => {});
|
app.listen(PORT, () => {
|
||||||
|
});
|
||||||
@@ -41,85 +41,339 @@ const dbPath = path.join(dataDir, 'db.sqlite');
|
|||||||
const sqlite = new Database(dbPath);
|
const sqlite = new Database(dbPath);
|
||||||
|
|
||||||
sqlite.exec(`
|
sqlite.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users
|
||||||
id TEXT PRIMARY KEY,
|
(
|
||||||
username TEXT NOT NULL,
|
id
|
||||||
password_hash TEXT NOT NULL,
|
TEXT
|
||||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
PRIMARY
|
||||||
|
KEY,
|
||||||
|
username
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
password_hash
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
is_admin
|
||||||
|
INTEGER
|
||||||
|
NOT
|
||||||
|
NULL
|
||||||
|
DEFAULT
|
||||||
|
0,
|
||||||
|
|
||||||
is_oidc INTEGER NOT NULL DEFAULT 0,
|
is_oidc
|
||||||
client_id TEXT NOT NULL,
|
INTEGER
|
||||||
client_secret TEXT NOT NULL,
|
NOT
|
||||||
issuer_url TEXT NOT NULL,
|
NULL
|
||||||
authorization_url TEXT NOT NULL,
|
DEFAULT
|
||||||
token_url TEXT NOT NULL,
|
0,
|
||||||
redirect_uri TEXT,
|
client_id
|
||||||
identifier_path TEXT NOT NULL,
|
TEXT
|
||||||
name_path TEXT NOT NULL,
|
NOT
|
||||||
scopes TEXT NOT NULL
|
NULL,
|
||||||
|
client_secret
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
issuer_url
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
authorization_url
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
token_url
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
redirect_uri
|
||||||
|
TEXT,
|
||||||
|
identifier_path
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
name_path
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
scopes
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
CREATE TABLE IF NOT EXISTS settings
|
||||||
key TEXT PRIMARY KEY,
|
(
|
||||||
value TEXT NOT NULL
|
key
|
||||||
|
TEXT
|
||||||
|
PRIMARY
|
||||||
|
KEY,
|
||||||
|
value
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ssh_data (
|
CREATE TABLE IF NOT EXISTS ssh_data
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
(
|
||||||
user_id TEXT NOT NULL,
|
id
|
||||||
name TEXT,
|
INTEGER
|
||||||
ip TEXT NOT NULL,
|
PRIMARY
|
||||||
port INTEGER NOT NULL,
|
KEY
|
||||||
username TEXT NOT NULL,
|
AUTOINCREMENT,
|
||||||
folder TEXT,
|
user_id
|
||||||
tags TEXT,
|
TEXT
|
||||||
pin INTEGER NOT NULL DEFAULT 0,
|
NOT
|
||||||
auth_type TEXT NOT NULL,
|
NULL,
|
||||||
password TEXT,
|
name
|
||||||
key TEXT,
|
TEXT,
|
||||||
key_password TEXT,
|
ip
|
||||||
key_type TEXT,
|
TEXT
|
||||||
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
NOT
|
||||||
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
NULL,
|
||||||
tunnel_connections TEXT,
|
port
|
||||||
enable_config_editor INTEGER NOT NULL DEFAULT 1,
|
INTEGER
|
||||||
default_path TEXT,
|
NOT
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
NULL,
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
username
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
folder
|
||||||
|
TEXT,
|
||||||
|
tags
|
||||||
|
TEXT,
|
||||||
|
pin
|
||||||
|
INTEGER
|
||||||
|
NOT
|
||||||
|
NULL
|
||||||
|
DEFAULT
|
||||||
|
0,
|
||||||
|
auth_type
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
password
|
||||||
|
TEXT,
|
||||||
|
key
|
||||||
|
TEXT,
|
||||||
|
key_password
|
||||||
|
TEXT,
|
||||||
|
key_type
|
||||||
|
TEXT,
|
||||||
|
enable_terminal
|
||||||
|
INTEGER
|
||||||
|
NOT
|
||||||
|
NULL
|
||||||
|
DEFAULT
|
||||||
|
1,
|
||||||
|
enable_tunnel
|
||||||
|
INTEGER
|
||||||
|
NOT
|
||||||
|
NULL
|
||||||
|
DEFAULT
|
||||||
|
1,
|
||||||
|
tunnel_connections
|
||||||
|
TEXT,
|
||||||
|
enable_file_manager
|
||||||
|
INTEGER
|
||||||
|
NOT
|
||||||
|
NULL
|
||||||
|
DEFAULT
|
||||||
|
1,
|
||||||
|
default_path
|
||||||
|
TEXT,
|
||||||
|
created_at
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL
|
||||||
|
DEFAULT
|
||||||
|
CURRENT_TIMESTAMP,
|
||||||
|
updated_at
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL
|
||||||
|
DEFAULT
|
||||||
|
CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN
|
||||||
|
KEY
|
||||||
|
(
|
||||||
|
user_id
|
||||||
|
) REFERENCES users
|
||||||
|
(
|
||||||
|
id
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS config_editor_recent (
|
CREATE TABLE IF NOT EXISTS file_manager_recent
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
(
|
||||||
user_id TEXT NOT NULL,
|
id
|
||||||
host_id INTEGER NOT NULL,
|
INTEGER
|
||||||
name TEXT NOT NULL,
|
PRIMARY
|
||||||
path TEXT NOT NULL,
|
KEY
|
||||||
last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
AUTOINCREMENT,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
user_id
|
||||||
FOREIGN KEY (host_id) REFERENCES ssh_data(id)
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
host_id
|
||||||
|
INTEGER
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
name
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
path
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
last_opened
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL
|
||||||
|
DEFAULT
|
||||||
|
CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN
|
||||||
|
KEY
|
||||||
|
(
|
||||||
|
user_id
|
||||||
|
) REFERENCES users
|
||||||
|
(
|
||||||
|
id
|
||||||
|
),
|
||||||
|
FOREIGN KEY
|
||||||
|
(
|
||||||
|
host_id
|
||||||
|
) REFERENCES ssh_data
|
||||||
|
(
|
||||||
|
id
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS config_editor_pinned (
|
CREATE TABLE IF NOT EXISTS file_manager_pinned
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
(
|
||||||
user_id TEXT NOT NULL,
|
id
|
||||||
host_id INTEGER NOT NULL,
|
INTEGER
|
||||||
name TEXT NOT NULL,
|
PRIMARY
|
||||||
path TEXT NOT NULL,
|
KEY
|
||||||
pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
AUTOINCREMENT,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
user_id
|
||||||
FOREIGN KEY (host_id) REFERENCES ssh_data(id)
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
host_id
|
||||||
|
INTEGER
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
name
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
path
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
pinned_at
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL
|
||||||
|
DEFAULT
|
||||||
|
CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN
|
||||||
|
KEY
|
||||||
|
(
|
||||||
|
user_id
|
||||||
|
) REFERENCES users
|
||||||
|
(
|
||||||
|
id
|
||||||
|
),
|
||||||
|
FOREIGN KEY
|
||||||
|
(
|
||||||
|
host_id
|
||||||
|
) REFERENCES ssh_data
|
||||||
|
(
|
||||||
|
id
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS config_editor_shortcuts (
|
CREATE TABLE IF NOT EXISTS file_manager_shortcuts
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
(
|
||||||
user_id TEXT NOT NULL,
|
id
|
||||||
host_id INTEGER NOT NULL,
|
INTEGER
|
||||||
name TEXT NOT NULL,
|
PRIMARY
|
||||||
path TEXT NOT NULL,
|
KEY
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
AUTOINCREMENT,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
user_id
|
||||||
FOREIGN KEY (host_id) REFERENCES ssh_data(id)
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
host_id
|
||||||
|
INTEGER
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
name
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
path
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
created_at
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL
|
||||||
|
DEFAULT
|
||||||
|
CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN
|
||||||
|
KEY
|
||||||
|
(
|
||||||
|
user_id
|
||||||
|
) REFERENCES users
|
||||||
|
(
|
||||||
|
id
|
||||||
|
),
|
||||||
|
FOREIGN KEY
|
||||||
|
(
|
||||||
|
host_id
|
||||||
|
) REFERENCES ssh_data
|
||||||
|
(
|
||||||
|
id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS dismissed_alerts
|
||||||
|
(
|
||||||
|
id
|
||||||
|
INTEGER
|
||||||
|
PRIMARY
|
||||||
|
KEY
|
||||||
|
AUTOINCREMENT,
|
||||||
|
user_id
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
alert_id
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
dismissed_at
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL
|
||||||
|
DEFAULT
|
||||||
|
CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN
|
||||||
|
KEY
|
||||||
|
(
|
||||||
|
user_id
|
||||||
|
) REFERENCES users
|
||||||
|
(
|
||||||
|
id
|
||||||
|
)
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -151,7 +405,6 @@ const migrateSchema = () => {
|
|||||||
addColumnIfNotExists('users', 'token_url', 'TEXT');
|
addColumnIfNotExists('users', 'token_url', 'TEXT');
|
||||||
try {
|
try {
|
||||||
sqlite.prepare(`ALTER TABLE users DROP COLUMN redirect_uri`).run();
|
sqlite.prepare(`ALTER TABLE users DROP COLUMN redirect_uri`).run();
|
||||||
logger.info('Removed redirect_uri column from users table');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,14 +424,14 @@ const migrateSchema = () => {
|
|||||||
addColumnIfNotExists('ssh_data', 'enable_terminal', 'INTEGER NOT NULL DEFAULT 1');
|
addColumnIfNotExists('ssh_data', 'enable_terminal', 'INTEGER NOT NULL DEFAULT 1');
|
||||||
addColumnIfNotExists('ssh_data', 'enable_tunnel', 'INTEGER NOT NULL DEFAULT 1');
|
addColumnIfNotExists('ssh_data', 'enable_tunnel', 'INTEGER NOT NULL DEFAULT 1');
|
||||||
addColumnIfNotExists('ssh_data', 'tunnel_connections', 'TEXT');
|
addColumnIfNotExists('ssh_data', 'tunnel_connections', 'TEXT');
|
||||||
addColumnIfNotExists('ssh_data', 'enable_config_editor', 'INTEGER NOT NULL DEFAULT 1');
|
addColumnIfNotExists('ssh_data', 'enable_file_manager', 'INTEGER NOT NULL DEFAULT 1');
|
||||||
addColumnIfNotExists('ssh_data', 'default_path', 'TEXT');
|
addColumnIfNotExists('ssh_data', 'default_path', 'TEXT');
|
||||||
addColumnIfNotExists('ssh_data', 'created_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
|
addColumnIfNotExists('ssh_data', 'created_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
|
||||||
addColumnIfNotExists('ssh_data', 'updated_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
|
addColumnIfNotExists('ssh_data', 'updated_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
|
||||||
|
|
||||||
addColumnIfNotExists('config_editor_recent', 'host_id', 'INTEGER NOT NULL');
|
addColumnIfNotExists('file_manager_recent', 'host_id', 'INTEGER NOT NULL');
|
||||||
addColumnIfNotExists('config_editor_pinned', 'host_id', 'INTEGER NOT NULL');
|
addColumnIfNotExists('file_manager_pinned', 'host_id', 'INTEGER NOT NULL');
|
||||||
addColumnIfNotExists('config_editor_shortcuts', 'host_id', 'INTEGER NOT NULL');
|
addColumnIfNotExists('file_manager_shortcuts', 'host_id', 'INTEGER NOT NULL');
|
||||||
|
|
||||||
logger.success('Schema migration completed');
|
logger.success('Schema migration completed');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -42,13 +42,13 @@ export const sshData = sqliteTable('ssh_data', {
|
|||||||
enableTerminal: integer('enable_terminal', {mode: 'boolean'}).notNull().default(true),
|
enableTerminal: integer('enable_terminal', {mode: 'boolean'}).notNull().default(true),
|
||||||
enableTunnel: integer('enable_tunnel', {mode: 'boolean'}).notNull().default(true),
|
enableTunnel: integer('enable_tunnel', {mode: 'boolean'}).notNull().default(true),
|
||||||
tunnelConnections: text('tunnel_connections'),
|
tunnelConnections: text('tunnel_connections'),
|
||||||
enableConfigEditor: integer('enable_config_editor', {mode: 'boolean'}).notNull().default(true),
|
enableFileManager: integer('enable_file_manager', {mode: 'boolean'}).notNull().default(true),
|
||||||
defaultPath: text('default_path'),
|
defaultPath: text('default_path'),
|
||||||
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||||
updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const configEditorRecent = sqliteTable('config_editor_recent', {
|
export const fileManagerRecent = sqliteTable('file_manager_recent', {
|
||||||
id: integer('id').primaryKey({autoIncrement: true}),
|
id: integer('id').primaryKey({autoIncrement: true}),
|
||||||
userId: text('user_id').notNull().references(() => users.id),
|
userId: text('user_id').notNull().references(() => users.id),
|
||||||
hostId: integer('host_id').notNull().references(() => sshData.id),
|
hostId: integer('host_id').notNull().references(() => sshData.id),
|
||||||
@@ -57,7 +57,7 @@ export const configEditorRecent = sqliteTable('config_editor_recent', {
|
|||||||
lastOpened: text('last_opened').notNull().default(sql`CURRENT_TIMESTAMP`),
|
lastOpened: text('last_opened').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const configEditorPinned = sqliteTable('config_editor_pinned', {
|
export const fileManagerPinned = sqliteTable('file_manager_pinned', {
|
||||||
id: integer('id').primaryKey({autoIncrement: true}),
|
id: integer('id').primaryKey({autoIncrement: true}),
|
||||||
userId: text('user_id').notNull().references(() => users.id),
|
userId: text('user_id').notNull().references(() => users.id),
|
||||||
hostId: integer('host_id').notNull().references(() => sshData.id),
|
hostId: integer('host_id').notNull().references(() => sshData.id),
|
||||||
@@ -66,7 +66,7 @@ export const configEditorPinned = sqliteTable('config_editor_pinned', {
|
|||||||
pinnedAt: text('pinned_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
pinnedAt: text('pinned_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const configEditorShortcuts = sqliteTable('config_editor_shortcuts', {
|
export const fileManagerShortcuts = sqliteTable('file_manager_shortcuts', {
|
||||||
id: integer('id').primaryKey({autoIncrement: true}),
|
id: integer('id').primaryKey({autoIncrement: true}),
|
||||||
userId: text('user_id').notNull().references(() => users.id),
|
userId: text('user_id').notNull().references(() => users.id),
|
||||||
hostId: integer('host_id').notNull().references(() => sshData.id),
|
hostId: integer('host_id').notNull().references(() => sshData.id),
|
||||||
@@ -74,3 +74,10 @@ export const configEditorShortcuts = sqliteTable('config_editor_shortcuts', {
|
|||||||
path: text('path').notNull(),
|
path: text('path').notNull(),
|
||||||
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const dismissedAlerts = sqliteTable('dismissed_alerts', {
|
||||||
|
id: integer('id').primaryKey({autoIncrement: true}),
|
||||||
|
userId: text('user_id').notNull().references(() => users.id),
|
||||||
|
alertId: text('alert_id').notNull(),
|
||||||
|
dismissedAt: text('dismissed_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
});
|
||||||
270
src/backend/database/routes/alerts.ts
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import {db} from '../db/index.js';
|
||||||
|
import {dismissedAlerts} from '../db/schema.js';
|
||||||
|
import {eq, and} from 'drizzle-orm';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import fetch from 'node-fetch';
|
||||||
|
import type {Request, Response, NextFunction} from 'express';
|
||||||
|
|
||||||
|
const dbIconSymbol = '🚨';
|
||||||
|
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||||
|
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
||||||
|
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#dc2626')(`[${dbIconSymbol}]`)} ${message}`;
|
||||||
|
};
|
||||||
|
const logger = {
|
||||||
|
info: (msg: string): void => {
|
||||||
|
console.log(formatMessage('info', chalk.cyan, msg));
|
||||||
|
},
|
||||||
|
warn: (msg: string): void => {
|
||||||
|
console.warn(formatMessage('warn', chalk.yellow, msg));
|
||||||
|
},
|
||||||
|
error: (msg: string, err?: unknown): void => {
|
||||||
|
console.error(formatMessage('error', chalk.redBright, msg));
|
||||||
|
if (err) console.error(err);
|
||||||
|
},
|
||||||
|
success: (msg: string): void => {
|
||||||
|
console.log(formatMessage('success', chalk.greenBright, msg));
|
||||||
|
},
|
||||||
|
debug: (msg: string): void => {
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
console.debug(formatMessage('debug', chalk.magenta, msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CacheEntry {
|
||||||
|
data: any;
|
||||||
|
timestamp: number;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AlertCache {
|
||||||
|
private cache: Map<string, CacheEntry> = new Map();
|
||||||
|
private readonly CACHE_DURATION = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
set(key: string, data: any): void {
|
||||||
|
const now = Date.now();
|
||||||
|
this.cache.set(key, {
|
||||||
|
data,
|
||||||
|
timestamp: now,
|
||||||
|
expiresAt: now + this.CACHE_DURATION
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key: string): any | null {
|
||||||
|
const entry = this.cache.get(key);
|
||||||
|
if (!entry) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Date.now() > entry.expiresAt) {
|
||||||
|
this.cache.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const alertCache = new AlertCache();
|
||||||
|
|
||||||
|
const GITHUB_RAW_BASE = 'https://raw.githubusercontent.com';
|
||||||
|
const REPO_OWNER = 'LukeGus';
|
||||||
|
const REPO_NAME = 'Termix-Docs';
|
||||||
|
const ALERTS_FILE = 'main/termix-alerts.json';
|
||||||
|
|
||||||
|
interface TermixAlert {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
expiresAt: string;
|
||||||
|
priority?: 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
type?: 'info' | 'warning' | 'error' | 'success';
|
||||||
|
actionUrl?: string;
|
||||||
|
actionText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
|
||||||
|
const cacheKey = 'termix_alerts';
|
||||||
|
const cachedData = alertCache.get(cacheKey);
|
||||||
|
if (cachedData) {
|
||||||
|
return cachedData;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${ALERTS_FILE}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'User-Agent': 'TermixAlertChecker/1.0'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`GitHub raw content error: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const alerts: TermixAlert[] = await response.json() as TermixAlert[];
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const validAlerts = alerts.filter(alert => {
|
||||||
|
const expiryDate = new Date(alert.expiresAt);
|
||||||
|
const isValid = expiryDate > now;
|
||||||
|
return isValid;
|
||||||
|
});
|
||||||
|
|
||||||
|
alertCache.set(cacheKey, validAlerts);
|
||||||
|
return validAlerts;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to fetch alerts from GitHub', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Route: Get all active alerts
|
||||||
|
// GET /alerts
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const alerts = await fetchAlertsFromGitHub();
|
||||||
|
res.json({
|
||||||
|
alerts,
|
||||||
|
cached: alertCache.get('termix_alerts') !== null,
|
||||||
|
total_count: alerts.length
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get alerts', error);
|
||||||
|
res.status(500).json({error: 'Failed to fetch alerts'});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route: Get alerts for a specific user (excluding dismissed ones)
|
||||||
|
// GET /alerts/user/:userId
|
||||||
|
router.get('/user/:userId', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {userId} = req.params;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return res.status(400).json({error: 'User ID is required'});
|
||||||
|
}
|
||||||
|
|
||||||
|
const allAlerts = await fetchAlertsFromGitHub();
|
||||||
|
|
||||||
|
const dismissedAlertRecords = await db
|
||||||
|
.select({alertId: dismissedAlerts.alertId})
|
||||||
|
.from(dismissedAlerts)
|
||||||
|
.where(eq(dismissedAlerts.userId, userId));
|
||||||
|
|
||||||
|
const dismissedAlertIds = new Set(dismissedAlertRecords.map(record => record.alertId));
|
||||||
|
|
||||||
|
const userAlerts = allAlerts.filter(alert => !dismissedAlertIds.has(alert.id));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
alerts: userAlerts,
|
||||||
|
total_count: userAlerts.length,
|
||||||
|
dismissed_count: dismissedAlertIds.size
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get user alerts', error);
|
||||||
|
res.status(500).json({error: 'Failed to fetch user alerts'});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route: Dismiss an alert for a user
|
||||||
|
// POST /alerts/dismiss
|
||||||
|
router.post('/dismiss', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {userId, alertId} = req.body;
|
||||||
|
|
||||||
|
if (!userId || !alertId) {
|
||||||
|
logger.warn('Missing userId or alertId in dismiss request');
|
||||||
|
return res.status(400).json({error: 'User ID and Alert ID are required'});
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingDismissal = await db
|
||||||
|
.select()
|
||||||
|
.from(dismissedAlerts)
|
||||||
|
.where(and(
|
||||||
|
eq(dismissedAlerts.userId, userId),
|
||||||
|
eq(dismissedAlerts.alertId, alertId)
|
||||||
|
));
|
||||||
|
|
||||||
|
if (existingDismissal.length > 0) {
|
||||||
|
logger.warn(`Alert ${alertId} already dismissed by user ${userId}`);
|
||||||
|
return res.status(409).json({error: 'Alert already dismissed'});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.insert(dismissedAlerts).values({
|
||||||
|
userId,
|
||||||
|
alertId
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.success(`Alert ${alertId} dismissed by user ${userId}. Insert result: ${JSON.stringify(result)}`);
|
||||||
|
res.json({message: 'Alert dismissed successfully'});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to dismiss alert', error);
|
||||||
|
res.status(500).json({error: 'Failed to dismiss alert'});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route: Get dismissed alerts for a user
|
||||||
|
// GET /alerts/dismissed/:userId
|
||||||
|
router.get('/dismissed/:userId', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {userId} = req.params;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return res.status(400).json({error: 'User ID is required'});
|
||||||
|
}
|
||||||
|
|
||||||
|
const dismissedAlertRecords = await db
|
||||||
|
.select({
|
||||||
|
alertId: dismissedAlerts.alertId,
|
||||||
|
dismissedAt: dismissedAlerts.dismissedAt
|
||||||
|
})
|
||||||
|
.from(dismissedAlerts)
|
||||||
|
.where(eq(dismissedAlerts.userId, userId));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
dismissed_alerts: dismissedAlertRecords,
|
||||||
|
total_count: dismissedAlertRecords.length
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get dismissed alerts', error);
|
||||||
|
res.status(500).json({error: 'Failed to fetch dismissed alerts'});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route: Undismiss an alert for a user (remove from dismissed list)
|
||||||
|
// DELETE /alerts/dismiss
|
||||||
|
router.delete('/dismiss', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {userId, alertId} = req.body;
|
||||||
|
|
||||||
|
if (!userId || !alertId) {
|
||||||
|
return res.status(400).json({error: 'User ID and Alert ID are required'});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.delete(dismissedAlerts)
|
||||||
|
.where(and(
|
||||||
|
eq(dismissedAlerts.userId, userId),
|
||||||
|
eq(dismissedAlerts.alertId, alertId)
|
||||||
|
));
|
||||||
|
|
||||||
|
if (result.changes === 0) {
|
||||||
|
return res.status(404).json({error: 'Dismissed alert not found'});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`Alert ${alertId} undismissed by user ${userId}`);
|
||||||
|
res.json({message: 'Alert undismissed successfully'});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to undismiss alert', error);
|
||||||
|
res.status(500).json({error: 'Failed to undismiss alert'});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import {db} from '../db/index.js';
|
import {db} from '../db/index.js';
|
||||||
import {sshData, configEditorRecent, configEditorPinned, configEditorShortcuts} from '../db/schema.js';
|
import {sshData, fileManagerRecent, fileManagerPinned, fileManagerShortcuts} from '../db/schema.js';
|
||||||
import {eq, and, desc} from 'drizzle-orm';
|
import {eq, and, desc} from 'drizzle-orm';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
@@ -94,7 +94,6 @@ router.get('/db/host/internal', async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const data = await db.select().from(sshData);
|
const data = await db.select().from(sshData);
|
||||||
// Convert tags to array, booleans to bool, tunnelConnections to array
|
|
||||||
const result = data.map((row: any) => ({
|
const result = data.map((row: any) => ({
|
||||||
...row,
|
...row,
|
||||||
tags: typeof row.tags === 'string' ? (row.tags ? row.tags.split(',').filter(Boolean) : []) : [],
|
tags: typeof row.tags === 'string' ? (row.tags ? row.tags.split(',').filter(Boolean) : []) : [],
|
||||||
@@ -102,7 +101,7 @@ router.get('/db/host/internal', async (req: Request, res: Response) => {
|
|||||||
enableTerminal: !!row.enableTerminal,
|
enableTerminal: !!row.enableTerminal,
|
||||||
enableTunnel: !!row.enableTunnel,
|
enableTunnel: !!row.enableTunnel,
|
||||||
tunnelConnections: row.tunnelConnections ? JSON.parse(row.tunnelConnections) : [],
|
tunnelConnections: row.tunnelConnections ? JSON.parse(row.tunnelConnections) : [],
|
||||||
enableConfigEditor: !!row.enableConfigEditor,
|
enableFileManager: !!row.enableFileManager,
|
||||||
}));
|
}));
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -116,9 +115,7 @@ router.get('/db/host/internal', async (req: Request, res: Response) => {
|
|||||||
router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => {
|
router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => {
|
||||||
let hostData: any;
|
let hostData: any;
|
||||||
|
|
||||||
// Check if this is a multipart form data request (file upload)
|
|
||||||
if (req.headers['content-type']?.includes('multipart/form-data')) {
|
if (req.headers['content-type']?.includes('multipart/form-data')) {
|
||||||
// Parse the JSON data from the 'data' field
|
|
||||||
if (req.body.data) {
|
if (req.body.data) {
|
||||||
try {
|
try {
|
||||||
hostData = JSON.parse(req.body.data);
|
hostData = JSON.parse(req.body.data);
|
||||||
@@ -131,12 +128,10 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
|
|||||||
return res.status(400).json({error: 'Missing data field'});
|
return res.status(400).json({error: 'Missing data field'});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the file data if present
|
|
||||||
if (req.file) {
|
if (req.file) {
|
||||||
hostData.key = req.file.buffer.toString('utf8');
|
hostData.key = req.file.buffer.toString('utf8');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Regular JSON request
|
|
||||||
hostData = req.body;
|
hostData = req.body;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +150,7 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
|
|||||||
pin,
|
pin,
|
||||||
enableTerminal,
|
enableTerminal,
|
||||||
enableTunnel,
|
enableTunnel,
|
||||||
enableConfigEditor,
|
enableFileManager,
|
||||||
defaultPath,
|
defaultPath,
|
||||||
tunnelConnections
|
tunnelConnections
|
||||||
} = hostData;
|
} = hostData;
|
||||||
@@ -178,7 +173,7 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
|
|||||||
enableTerminal: !!enableTerminal ? 1 : 0,
|
enableTerminal: !!enableTerminal ? 1 : 0,
|
||||||
enableTunnel: !!enableTunnel ? 1 : 0,
|
enableTunnel: !!enableTunnel ? 1 : 0,
|
||||||
tunnelConnections: Array.isArray(tunnelConnections) ? JSON.stringify(tunnelConnections) : null,
|
tunnelConnections: Array.isArray(tunnelConnections) ? JSON.stringify(tunnelConnections) : null,
|
||||||
enableConfigEditor: !!enableConfigEditor ? 1 : 0,
|
enableFileManager: !!enableFileManager ? 1 : 0,
|
||||||
defaultPath: defaultPath || null,
|
defaultPath: defaultPath || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -243,7 +238,7 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re
|
|||||||
pin,
|
pin,
|
||||||
enableTerminal,
|
enableTerminal,
|
||||||
enableTunnel,
|
enableTunnel,
|
||||||
enableConfigEditor,
|
enableFileManager,
|
||||||
defaultPath,
|
defaultPath,
|
||||||
tunnelConnections
|
tunnelConnections
|
||||||
} = hostData;
|
} = hostData;
|
||||||
@@ -266,7 +261,7 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re
|
|||||||
enableTerminal: !!enableTerminal ? 1 : 0,
|
enableTerminal: !!enableTerminal ? 1 : 0,
|
||||||
enableTunnel: !!enableTunnel ? 1 : 0,
|
enableTunnel: !!enableTunnel ? 1 : 0,
|
||||||
tunnelConnections: Array.isArray(tunnelConnections) ? JSON.stringify(tunnelConnections) : null,
|
tunnelConnections: Array.isArray(tunnelConnections) ? JSON.stringify(tunnelConnections) : null,
|
||||||
enableConfigEditor: !!enableConfigEditor ? 1 : 0,
|
enableFileManager: !!enableFileManager ? 1 : 0,
|
||||||
defaultPath: defaultPath || null,
|
defaultPath: defaultPath || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -313,7 +308,7 @@ router.get('/db/host', authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
enableTerminal: !!row.enableTerminal,
|
enableTerminal: !!row.enableTerminal,
|
||||||
enableTunnel: !!row.enableTunnel,
|
enableTunnel: !!row.enableTunnel,
|
||||||
tunnelConnections: row.tunnelConnections ? JSON.parse(row.tunnelConnections) : [],
|
tunnelConnections: row.tunnelConnections ? JSON.parse(row.tunnelConnections) : [],
|
||||||
enableConfigEditor: !!row.enableConfigEditor,
|
enableFileManager: !!row.enableFileManager,
|
||||||
}));
|
}));
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -351,7 +346,7 @@ router.get('/db/host/:id', authenticateJWT, async (req: Request, res: Response)
|
|||||||
enableTerminal: !!host.enableTerminal,
|
enableTerminal: !!host.enableTerminal,
|
||||||
enableTunnel: !!host.enableTunnel,
|
enableTunnel: !!host.enableTunnel,
|
||||||
tunnelConnections: host.tunnelConnections ? JSON.parse(host.tunnelConnections) : [],
|
tunnelConnections: host.tunnelConnections ? JSON.parse(host.tunnelConnections) : [],
|
||||||
enableConfigEditor: !!host.enableConfigEditor,
|
enableFileManager: !!host.enableFileManager,
|
||||||
};
|
};
|
||||||
|
|
||||||
res.json(result);
|
res.json(result);
|
||||||
@@ -411,8 +406,8 @@ router.delete('/db/host/:id', authenticateJWT, async (req: Request, res: Respons
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Route: Get recent files (requires JWT)
|
// Route: Get recent files (requires JWT)
|
||||||
// GET /ssh/config_editor/recent
|
// GET /ssh/file_manager/recent
|
||||||
router.get('/config_editor/recent', authenticateJWT, async (req: Request, res: Response) => {
|
router.get('/file_manager/recent', authenticateJWT, async (req: Request, res: Response) => {
|
||||||
const userId = (req as any).userId;
|
const userId = (req as any).userId;
|
||||||
const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null;
|
const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null;
|
||||||
|
|
||||||
@@ -429,12 +424,12 @@ router.get('/config_editor/recent', authenticateJWT, async (req: Request, res: R
|
|||||||
try {
|
try {
|
||||||
const recentFiles = await db
|
const recentFiles = await db
|
||||||
.select()
|
.select()
|
||||||
.from(configEditorRecent)
|
.from(fileManagerRecent)
|
||||||
.where(and(
|
.where(and(
|
||||||
eq(configEditorRecent.userId, userId),
|
eq(fileManagerRecent.userId, userId),
|
||||||
eq(configEditorRecent.hostId, hostId)
|
eq(fileManagerRecent.hostId, hostId)
|
||||||
))
|
))
|
||||||
.orderBy(desc(configEditorRecent.lastOpened));
|
.orderBy(desc(fileManagerRecent.lastOpened));
|
||||||
res.json(recentFiles);
|
res.json(recentFiles);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to fetch recent files', err);
|
logger.error('Failed to fetch recent files', err);
|
||||||
@@ -443,8 +438,8 @@ router.get('/config_editor/recent', authenticateJWT, async (req: Request, res: R
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Route: Add file to recent (requires JWT)
|
// Route: Add file to recent (requires JWT)
|
||||||
// POST /ssh/config_editor/recent
|
// POST /ssh/file_manager/recent
|
||||||
router.post('/config_editor/recent', authenticateJWT, async (req: Request, res: Response) => {
|
router.post('/file_manager/recent', authenticateJWT, async (req: Request, res: Response) => {
|
||||||
const userId = (req as any).userId;
|
const userId = (req as any).userId;
|
||||||
const {name, path, hostId} = req.body;
|
const {name, path, hostId} = req.body;
|
||||||
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
|
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
|
||||||
@@ -453,24 +448,23 @@ router.post('/config_editor/recent', authenticateJWT, async (req: Request, res:
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const conditions = [
|
const conditions = [
|
||||||
eq(configEditorRecent.userId, userId),
|
eq(fileManagerRecent.userId, userId),
|
||||||
eq(configEditorRecent.path, path),
|
eq(fileManagerRecent.path, path),
|
||||||
eq(configEditorRecent.hostId, hostId)
|
eq(fileManagerRecent.hostId, hostId)
|
||||||
];
|
];
|
||||||
|
|
||||||
const existing = await db
|
const existing = await db
|
||||||
.select()
|
.select()
|
||||||
.from(configEditorRecent)
|
.from(fileManagerRecent)
|
||||||
.where(and(...conditions));
|
.where(and(...conditions));
|
||||||
|
|
||||||
if (existing.length > 0) {
|
if (existing.length > 0) {
|
||||||
await db
|
await db
|
||||||
.update(configEditorRecent)
|
.update(fileManagerRecent)
|
||||||
.set({lastOpened: new Date().toISOString()})
|
.set({lastOpened: new Date().toISOString()})
|
||||||
.where(and(...conditions));
|
.where(and(...conditions));
|
||||||
} else {
|
} else {
|
||||||
// Add new recent file
|
await db.insert(fileManagerRecent).values({
|
||||||
await db.insert(configEditorRecent).values({
|
|
||||||
userId,
|
userId,
|
||||||
hostId,
|
hostId,
|
||||||
name,
|
name,
|
||||||
@@ -486,8 +480,8 @@ router.post('/config_editor/recent', authenticateJWT, async (req: Request, res:
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Route: Remove file from recent (requires JWT)
|
// Route: Remove file from recent (requires JWT)
|
||||||
// DELETE /ssh/config_editor/recent
|
// DELETE /ssh/file_manager/recent
|
||||||
router.delete('/config_editor/recent', authenticateJWT, async (req: Request, res: Response) => {
|
router.delete('/file_manager/recent', authenticateJWT, async (req: Request, res: Response) => {
|
||||||
const userId = (req as any).userId;
|
const userId = (req as any).userId;
|
||||||
const {name, path, hostId} = req.body;
|
const {name, path, hostId} = req.body;
|
||||||
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
|
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
|
||||||
@@ -496,13 +490,13 @@ router.delete('/config_editor/recent', authenticateJWT, async (req: Request, res
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const conditions = [
|
const conditions = [
|
||||||
eq(configEditorRecent.userId, userId),
|
eq(fileManagerRecent.userId, userId),
|
||||||
eq(configEditorRecent.path, path),
|
eq(fileManagerRecent.path, path),
|
||||||
eq(configEditorRecent.hostId, hostId)
|
eq(fileManagerRecent.hostId, hostId)
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = await db
|
const result = await db
|
||||||
.delete(configEditorRecent)
|
.delete(fileManagerRecent)
|
||||||
.where(and(...conditions));
|
.where(and(...conditions));
|
||||||
res.json({message: 'File removed from recent'});
|
res.json({message: 'File removed from recent'});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -512,8 +506,8 @@ router.delete('/config_editor/recent', authenticateJWT, async (req: Request, res
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Route: Get pinned files (requires JWT)
|
// Route: Get pinned files (requires JWT)
|
||||||
// GET /ssh/config_editor/pinned
|
// GET /ssh/file_manager/pinned
|
||||||
router.get('/config_editor/pinned', authenticateJWT, async (req: Request, res: Response) => {
|
router.get('/file_manager/pinned', authenticateJWT, async (req: Request, res: Response) => {
|
||||||
const userId = (req as any).userId;
|
const userId = (req as any).userId;
|
||||||
const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null;
|
const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null;
|
||||||
|
|
||||||
@@ -530,12 +524,12 @@ router.get('/config_editor/pinned', authenticateJWT, async (req: Request, res: R
|
|||||||
try {
|
try {
|
||||||
const pinnedFiles = await db
|
const pinnedFiles = await db
|
||||||
.select()
|
.select()
|
||||||
.from(configEditorPinned)
|
.from(fileManagerPinned)
|
||||||
.where(and(
|
.where(and(
|
||||||
eq(configEditorPinned.userId, userId),
|
eq(fileManagerPinned.userId, userId),
|
||||||
eq(configEditorPinned.hostId, hostId)
|
eq(fileManagerPinned.hostId, hostId)
|
||||||
))
|
))
|
||||||
.orderBy(configEditorPinned.pinnedAt);
|
.orderBy(fileManagerPinned.pinnedAt);
|
||||||
res.json(pinnedFiles);
|
res.json(pinnedFiles);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to fetch pinned files', err);
|
logger.error('Failed to fetch pinned files', err);
|
||||||
@@ -544,8 +538,8 @@ router.get('/config_editor/pinned', authenticateJWT, async (req: Request, res: R
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Route: Add file to pinned (requires JWT)
|
// Route: Add file to pinned (requires JWT)
|
||||||
// POST /ssh/config_editor/pinned
|
// POST /ssh/file_manager/pinned
|
||||||
router.post('/config_editor/pinned', authenticateJWT, async (req: Request, res: Response) => {
|
router.post('/file_manager/pinned', authenticateJWT, async (req: Request, res: Response) => {
|
||||||
const userId = (req as any).userId;
|
const userId = (req as any).userId;
|
||||||
const {name, path, hostId} = req.body;
|
const {name, path, hostId} = req.body;
|
||||||
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
|
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
|
||||||
@@ -554,18 +548,18 @@ router.post('/config_editor/pinned', authenticateJWT, async (req: Request, res:
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const conditions = [
|
const conditions = [
|
||||||
eq(configEditorPinned.userId, userId),
|
eq(fileManagerPinned.userId, userId),
|
||||||
eq(configEditorPinned.path, path),
|
eq(fileManagerPinned.path, path),
|
||||||
eq(configEditorPinned.hostId, hostId)
|
eq(fileManagerPinned.hostId, hostId)
|
||||||
];
|
];
|
||||||
|
|
||||||
const existing = await db
|
const existing = await db
|
||||||
.select()
|
.select()
|
||||||
.from(configEditorPinned)
|
.from(fileManagerPinned)
|
||||||
.where(and(...conditions));
|
.where(and(...conditions));
|
||||||
|
|
||||||
if (existing.length === 0) {
|
if (existing.length === 0) {
|
||||||
await db.insert(configEditorPinned).values({
|
await db.insert(fileManagerPinned).values({
|
||||||
userId,
|
userId,
|
||||||
hostId,
|
hostId,
|
||||||
name,
|
name,
|
||||||
@@ -581,8 +575,8 @@ router.post('/config_editor/pinned', authenticateJWT, async (req: Request, res:
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Route: Remove file from pinned (requires JWT)
|
// Route: Remove file from pinned (requires JWT)
|
||||||
// DELETE /ssh/config_editor/pinned
|
// DELETE /ssh/file_manager/pinned
|
||||||
router.delete('/config_editor/pinned', authenticateJWT, async (req: Request, res: Response) => {
|
router.delete('/file_manager/pinned', authenticateJWT, async (req: Request, res: Response) => {
|
||||||
const userId = (req as any).userId;
|
const userId = (req as any).userId;
|
||||||
const {name, path, hostId} = req.body;
|
const {name, path, hostId} = req.body;
|
||||||
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
|
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
|
||||||
@@ -591,13 +585,13 @@ router.delete('/config_editor/pinned', authenticateJWT, async (req: Request, res
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const conditions = [
|
const conditions = [
|
||||||
eq(configEditorPinned.userId, userId),
|
eq(fileManagerPinned.userId, userId),
|
||||||
eq(configEditorPinned.path, path),
|
eq(fileManagerPinned.path, path),
|
||||||
eq(configEditorPinned.hostId, hostId)
|
eq(fileManagerPinned.hostId, hostId)
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = await db
|
const result = await db
|
||||||
.delete(configEditorPinned)
|
.delete(fileManagerPinned)
|
||||||
.where(and(...conditions));
|
.where(and(...conditions));
|
||||||
res.json({message: 'File unpinned successfully'});
|
res.json({message: 'File unpinned successfully'});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -607,8 +601,8 @@ router.delete('/config_editor/pinned', authenticateJWT, async (req: Request, res
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Route: Get folder shortcuts (requires JWT)
|
// Route: Get folder shortcuts (requires JWT)
|
||||||
// GET /ssh/config_editor/shortcuts
|
// GET /ssh/file_manager/shortcuts
|
||||||
router.get('/config_editor/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
|
router.get('/file_manager/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
|
||||||
const userId = (req as any).userId;
|
const userId = (req as any).userId;
|
||||||
const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null;
|
const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null;
|
||||||
|
|
||||||
@@ -623,12 +617,12 @@ router.get('/config_editor/shortcuts', authenticateJWT, async (req: Request, res
|
|||||||
try {
|
try {
|
||||||
const shortcuts = await db
|
const shortcuts = await db
|
||||||
.select()
|
.select()
|
||||||
.from(configEditorShortcuts)
|
.from(fileManagerShortcuts)
|
||||||
.where(and(
|
.where(and(
|
||||||
eq(configEditorShortcuts.userId, userId),
|
eq(fileManagerShortcuts.userId, userId),
|
||||||
eq(configEditorShortcuts.hostId, hostId)
|
eq(fileManagerShortcuts.hostId, hostId)
|
||||||
))
|
))
|
||||||
.orderBy(configEditorShortcuts.createdAt);
|
.orderBy(fileManagerShortcuts.createdAt);
|
||||||
res.json(shortcuts);
|
res.json(shortcuts);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to fetch shortcuts', err);
|
logger.error('Failed to fetch shortcuts', err);
|
||||||
@@ -637,8 +631,8 @@ router.get('/config_editor/shortcuts', authenticateJWT, async (req: Request, res
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Route: Add folder shortcut (requires JWT)
|
// Route: Add folder shortcut (requires JWT)
|
||||||
// POST /ssh/config_editor/shortcuts
|
// POST /ssh/file_manager/shortcuts
|
||||||
router.post('/config_editor/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
|
router.post('/file_manager/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
|
||||||
const userId = (req as any).userId;
|
const userId = (req as any).userId;
|
||||||
const {name, path, hostId} = req.body;
|
const {name, path, hostId} = req.body;
|
||||||
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
|
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
|
||||||
@@ -646,18 +640,18 @@ router.post('/config_editor/shortcuts', authenticateJWT, async (req: Request, re
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const conditions = [
|
const conditions = [
|
||||||
eq(configEditorShortcuts.userId, userId),
|
eq(fileManagerShortcuts.userId, userId),
|
||||||
eq(configEditorShortcuts.path, path),
|
eq(fileManagerShortcuts.path, path),
|
||||||
eq(configEditorShortcuts.hostId, hostId)
|
eq(fileManagerShortcuts.hostId, hostId)
|
||||||
];
|
];
|
||||||
|
|
||||||
const existing = await db
|
const existing = await db
|
||||||
.select()
|
.select()
|
||||||
.from(configEditorShortcuts)
|
.from(fileManagerShortcuts)
|
||||||
.where(and(...conditions));
|
.where(and(...conditions));
|
||||||
|
|
||||||
if (existing.length === 0) {
|
if (existing.length === 0) {
|
||||||
await db.insert(configEditorShortcuts).values({
|
await db.insert(fileManagerShortcuts).values({
|
||||||
userId,
|
userId,
|
||||||
hostId,
|
hostId,
|
||||||
name,
|
name,
|
||||||
@@ -673,8 +667,8 @@ router.post('/config_editor/shortcuts', authenticateJWT, async (req: Request, re
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Route: Remove folder shortcut (requires JWT)
|
// Route: Remove folder shortcut (requires JWT)
|
||||||
// DELETE /ssh/config_editor/shortcuts
|
// DELETE /ssh/file_manager/shortcuts
|
||||||
router.delete('/config_editor/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
|
router.delete('/file_manager/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
|
||||||
const userId = (req as any).userId;
|
const userId = (req as any).userId;
|
||||||
const {name, path, hostId} = req.body;
|
const {name, path, hostId} = req.body;
|
||||||
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
|
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
|
||||||
@@ -682,13 +676,13 @@ router.delete('/config_editor/shortcuts', authenticateJWT, async (req: Request,
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const conditions = [
|
const conditions = [
|
||||||
eq(configEditorShortcuts.userId, userId),
|
eq(fileManagerShortcuts.userId, userId),
|
||||||
eq(configEditorShortcuts.path, path),
|
eq(fileManagerShortcuts.path, path),
|
||||||
eq(configEditorShortcuts.hostId, hostId)
|
eq(fileManagerShortcuts.hostId, hostId)
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = await db
|
const result = await db
|
||||||
.delete(configEditorShortcuts)
|
.delete(fileManagerShortcuts)
|
||||||
.where(and(...conditions));
|
.where(and(...conditions));
|
||||||
res.json({message: 'Shortcut removed successfully'});
|
res.json({message: 'Shortcut removed successfully'});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -697,4 +691,116 @@ router.delete('/config_editor/shortcuts', authenticateJWT, async (req: Request,
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Route: Bulk import SSH hosts from JSON (requires JWT)
|
||||||
|
// POST /ssh/bulk-import
|
||||||
|
router.post('/bulk-import', authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
const {hosts} = req.body;
|
||||||
|
|
||||||
|
if (!Array.isArray(hosts) || hosts.length === 0) {
|
||||||
|
logger.warn('Invalid bulk import data - hosts array is required and must not be empty');
|
||||||
|
return res.status(400).json({error: 'Hosts array is required and must not be empty'});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hosts.length > 100) {
|
||||||
|
logger.warn(`Bulk import attempted with too many hosts: ${hosts.length}`);
|
||||||
|
return res.status(400).json({error: 'Maximum 100 hosts allowed per import'});
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = {
|
||||||
|
success: 0,
|
||||||
|
failed: 0,
|
||||||
|
errors: [] as string[]
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < hosts.length; i++) {
|
||||||
|
const hostData = hosts[i];
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port) || !isNonEmptyString(hostData.username)) {
|
||||||
|
results.failed++;
|
||||||
|
results.errors.push(`Host ${i + 1}: Missing or invalid required fields (ip, port, username)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hostData.authType !== 'password' && hostData.authType !== 'key') {
|
||||||
|
results.failed++;
|
||||||
|
results.errors.push(`Host ${i + 1}: Invalid authType. Must be 'password' or 'key'`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hostData.authType === 'password' && !isNonEmptyString(hostData.password)) {
|
||||||
|
results.failed++;
|
||||||
|
results.errors.push(`Host ${i + 1}: Password required for password authentication`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hostData.authType === 'key' && !isNonEmptyString(hostData.key)) {
|
||||||
|
results.failed++;
|
||||||
|
results.errors.push(`Host ${i + 1}: SSH key required for key authentication`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hostData.enableTunnel && Array.isArray(hostData.tunnelConnections)) {
|
||||||
|
for (let j = 0; j < hostData.tunnelConnections.length; j++) {
|
||||||
|
const conn = hostData.tunnelConnections[j];
|
||||||
|
if (!isValidPort(conn.sourcePort) || !isValidPort(conn.endpointPort) || !isNonEmptyString(conn.endpointHost)) {
|
||||||
|
results.failed++;
|
||||||
|
results.errors.push(`Host ${i + 1}, Tunnel ${j + 1}: Invalid tunnel connection data`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sshDataObj: any = {
|
||||||
|
userId: userId,
|
||||||
|
name: hostData.name || '',
|
||||||
|
folder: hostData.folder || '',
|
||||||
|
tags: Array.isArray(hostData.tags) ? hostData.tags.join(',') : (hostData.tags || ''),
|
||||||
|
ip: hostData.ip,
|
||||||
|
port: hostData.port,
|
||||||
|
username: hostData.username,
|
||||||
|
authType: hostData.authType,
|
||||||
|
pin: !!hostData.pin ? 1 : 0,
|
||||||
|
enableTerminal: !!hostData.enableTerminal ? 1 : 0,
|
||||||
|
enableTunnel: !!hostData.enableTunnel ? 1 : 0,
|
||||||
|
tunnelConnections: Array.isArray(hostData.tunnelConnections) ? JSON.stringify(hostData.tunnelConnections) : null,
|
||||||
|
enableFileManager: !!hostData.enableFileManager ? 1 : 0,
|
||||||
|
defaultPath: hostData.defaultPath || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (hostData.authType === 'password') {
|
||||||
|
sshDataObj.password = hostData.password;
|
||||||
|
sshDataObj.key = null;
|
||||||
|
sshDataObj.keyPassword = null;
|
||||||
|
sshDataObj.keyType = null;
|
||||||
|
} else if (hostData.authType === 'key') {
|
||||||
|
sshDataObj.key = hostData.key;
|
||||||
|
sshDataObj.keyPassword = hostData.keyPassword || null;
|
||||||
|
sshDataObj.keyType = hostData.keyType || null;
|
||||||
|
sshDataObj.password = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(sshData).values(sshDataObj);
|
||||||
|
results.success++;
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
results.failed++;
|
||||||
|
results.errors.push(`Host ${i + 1}: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||||
|
logger.error(`Failed to import host ${i + 1}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.success > 0) {
|
||||||
|
logger.success(`Bulk import completed: ${results.success} successful, ${results.failed} failed`);
|
||||||
|
} else {
|
||||||
|
logger.warn(`Bulk import failed: ${results.failed} failed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: `Import completed: ${results.success} successful, ${results.failed} failed`,
|
||||||
|
...results
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
@@ -79,7 +79,12 @@ async function verifyOIDCToken(idToken: string, issuerUrl: string, clientId: str
|
|||||||
const key = await importJWK(publicKey);
|
const key = await importJWK(publicKey);
|
||||||
|
|
||||||
const {payload} = await jwtVerify(idToken, key, {
|
const {payload} = await jwtVerify(idToken, key, {
|
||||||
issuer: issuerUrl,
|
issuer: [
|
||||||
|
issuerUrl,
|
||||||
|
normalizedIssuerUrl,
|
||||||
|
issuerUrl.replace(/\/application\/o\/[^\/]+$/, ''),
|
||||||
|
normalizedIssuerUrl.replace(/\/application\/o\/[^\/]+$/, '')
|
||||||
|
],
|
||||||
audience: clientId,
|
audience: clientId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -571,6 +576,7 @@ router.get('/me', authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
return res.status(401).json({error: 'User not found'});
|
return res.status(401).json({error: 'User not found'});
|
||||||
}
|
}
|
||||||
res.json({
|
res.json({
|
||||||
|
userId: user[0].id,
|
||||||
username: user[0].username,
|
username: user[0].username,
|
||||||
is_admin: !!user[0].is_admin,
|
is_admin: !!user[0].is_admin,
|
||||||
is_oidc: !!user[0].is_oidc
|
is_oidc: !!user[0].is_oidc
|
||||||
@@ -639,4 +645,351 @@ router.patch('/registration-allowed', authenticateJWT, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Route: Delete user account
|
||||||
|
// DELETE /users/delete-account
|
||||||
|
router.delete('/delete-account', authenticateJWT, async (req, res) => {
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
const {password} = req.body;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(password)) {
|
||||||
|
return res.status(400).json({error: 'Password is required to delete account'});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||||
|
if (!user || user.length === 0) {
|
||||||
|
return res.status(404).json({error: 'User not found'});
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRecord = user[0];
|
||||||
|
|
||||||
|
if (userRecord.is_oidc) {
|
||||||
|
return res.status(403).json({error: 'Cannot delete external authentication accounts through this endpoint'});
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMatch = await bcrypt.compare(password, userRecord.password_hash);
|
||||||
|
if (!isMatch) {
|
||||||
|
logger.warn(`Incorrect password provided for account deletion: ${userRecord.username}`);
|
||||||
|
return res.status(401).json({error: 'Incorrect password'});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userRecord.is_admin) {
|
||||||
|
const adminCount = db.$client.prepare('SELECT COUNT(*) as count FROM users WHERE is_admin = 1').get();
|
||||||
|
if ((adminCount as any)?.count <= 1) {
|
||||||
|
return res.status(403).json({error: 'Cannot delete the last admin user'});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(users).where(eq(users.id, userId));
|
||||||
|
|
||||||
|
logger.success(`User account deleted: ${userRecord.username}`);
|
||||||
|
res.json({message: 'Account deleted successfully'});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to delete user account', err);
|
||||||
|
res.status(500).json({error: 'Failed to delete account'});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route: Initiate password reset
|
||||||
|
// POST /users/initiate-reset
|
||||||
|
router.post('/initiate-reset', async (req, res) => {
|
||||||
|
const {username} = req.body;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(username)) {
|
||||||
|
return res.status(400).json({error: 'Username is required'});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.username, username));
|
||||||
|
|
||||||
|
if (!user || user.length === 0) {
|
||||||
|
logger.warn(`Password reset attempted for non-existent user: ${username}`);
|
||||||
|
return res.status(404).json({error: 'User not found'});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user[0].is_oidc) {
|
||||||
|
return res.status(403).json({error: 'Password reset not available for external authentication users'});
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetCode = Math.floor(100000 + Math.random() * 900000).toString();
|
||||||
|
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
|
||||||
|
|
||||||
|
db.$client.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)").run(
|
||||||
|
`reset_code_${username}`,
|
||||||
|
JSON.stringify({code: resetCode, expiresAt: expiresAt.toISOString()})
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`Password reset code for user ${username}: ${resetCode} (expires at ${expiresAt.toLocaleString()})`);
|
||||||
|
|
||||||
|
res.json({message: 'Password reset code has been generated and logged. Check docker logs for the code.'});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to initiate password reset', err);
|
||||||
|
res.status(500).json({error: 'Failed to initiate password reset'});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route: Verify reset code
|
||||||
|
// POST /users/verify-reset-code
|
||||||
|
router.post('/verify-reset-code', async (req, res) => {
|
||||||
|
const {username, resetCode} = req.body;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(username) || !isNonEmptyString(resetCode)) {
|
||||||
|
return res.status(400).json({error: 'Username and reset code are required'});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resetDataRow = db.$client.prepare("SELECT value FROM settings WHERE key = ?").get(`reset_code_${username}`);
|
||||||
|
if (!resetDataRow) {
|
||||||
|
return res.status(400).json({error: 'No reset code found for this user'});
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetData = JSON.parse((resetDataRow as any).value);
|
||||||
|
const now = new Date();
|
||||||
|
const expiresAt = new Date(resetData.expiresAt);
|
||||||
|
|
||||||
|
if (now > expiresAt) {
|
||||||
|
db.$client.prepare("DELETE FROM settings WHERE key = ?").run(`reset_code_${username}`);
|
||||||
|
return res.status(400).json({error: 'Reset code has expired'});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resetData.code !== resetCode) {
|
||||||
|
return res.status(400).json({error: 'Invalid reset code'});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempToken = nanoid();
|
||||||
|
const tempTokenExpiry = new Date(Date.now() + 10 * 60 * 1000);
|
||||||
|
|
||||||
|
db.$client.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)").run(
|
||||||
|
`temp_reset_token_${username}`,
|
||||||
|
JSON.stringify({token: tempToken, expiresAt: tempTokenExpiry.toISOString()})
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({message: 'Reset code verified', tempToken});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to verify reset code', err);
|
||||||
|
res.status(500).json({error: 'Failed to verify reset code'});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route: Complete password reset
|
||||||
|
// POST /users/complete-reset
|
||||||
|
router.post('/complete-reset', async (req, res) => {
|
||||||
|
const {username, tempToken, newPassword} = req.body;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(username) || !isNonEmptyString(tempToken) || !isNonEmptyString(newPassword)) {
|
||||||
|
return res.status(400).json({error: 'Username, temporary token, and new password are required'});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tempTokenRow = db.$client.prepare("SELECT value FROM settings WHERE key = ?").get(`temp_reset_token_${username}`);
|
||||||
|
if (!tempTokenRow) {
|
||||||
|
return res.status(400).json({error: 'No temporary token found'});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempTokenData = JSON.parse((tempTokenRow as any).value);
|
||||||
|
const now = new Date();
|
||||||
|
const expiresAt = new Date(tempTokenData.expiresAt);
|
||||||
|
|
||||||
|
if (now > expiresAt) {
|
||||||
|
// Clean up expired token
|
||||||
|
db.$client.prepare("DELETE FROM settings WHERE key = ?").run(`temp_reset_token_${username}`);
|
||||||
|
return res.status(400).json({error: 'Temporary token has expired'});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tempTokenData.token !== tempToken) {
|
||||||
|
return res.status(400).json({error: 'Invalid temporary token'});
|
||||||
|
}
|
||||||
|
|
||||||
|
const saltRounds = parseInt(process.env.SALT || '10', 10);
|
||||||
|
const password_hash = await bcrypt.hash(newPassword, saltRounds);
|
||||||
|
|
||||||
|
await db.update(users)
|
||||||
|
.set({password_hash})
|
||||||
|
.where(eq(users.username, username));
|
||||||
|
|
||||||
|
db.$client.prepare("DELETE FROM settings WHERE key = ?").run(`reset_code_${username}`);
|
||||||
|
db.$client.prepare("DELETE FROM settings WHERE key = ?").run(`temp_reset_token_${username}`);
|
||||||
|
|
||||||
|
logger.success(`Password successfully reset for user: ${username}`);
|
||||||
|
res.json({message: 'Password has been successfully reset'});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to complete password reset', err);
|
||||||
|
res.status(500).json({error: 'Failed to complete password reset'});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route: List all users (admin only)
|
||||||
|
// GET /users/list
|
||||||
|
router.get('/list', authenticateJWT, async (req, res) => {
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
try {
|
||||||
|
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||||
|
if (!user || user.length === 0 || !user[0].is_admin) {
|
||||||
|
return res.status(403).json({error: 'Not authorized'});
|
||||||
|
}
|
||||||
|
|
||||||
|
const allUsers = await db.select({
|
||||||
|
id: users.id,
|
||||||
|
username: users.username,
|
||||||
|
is_admin: users.is_admin,
|
||||||
|
is_oidc: users.is_oidc
|
||||||
|
}).from(users);
|
||||||
|
|
||||||
|
res.json({users: allUsers});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to list users', err);
|
||||||
|
res.status(500).json({error: 'Failed to list users'});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route: Make user admin (admin only)
|
||||||
|
// POST /users/make-admin
|
||||||
|
router.post('/make-admin', authenticateJWT, async (req, res) => {
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
const {username} = req.body;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(username)) {
|
||||||
|
return res.status(400).json({error: 'Username is required'});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const adminUser = await db.select().from(users).where(eq(users.id, userId));
|
||||||
|
if (!adminUser || adminUser.length === 0 || !adminUser[0].is_admin) {
|
||||||
|
return res.status(403).json({error: 'Not authorized'});
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUser = await db.select().from(users).where(eq(users.username, username));
|
||||||
|
if (!targetUser || targetUser.length === 0) {
|
||||||
|
return res.status(404).json({error: 'User not found'});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetUser[0].is_admin) {
|
||||||
|
return res.status(400).json({error: 'User is already an admin'});
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(users)
|
||||||
|
.set({is_admin: true})
|
||||||
|
.where(eq(users.username, username));
|
||||||
|
|
||||||
|
logger.success(`User ${username} made admin by ${adminUser[0].username}`);
|
||||||
|
res.json({message: `User ${username} is now an admin`});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to make user admin', err);
|
||||||
|
res.status(500).json({error: 'Failed to make user admin'});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route: Remove admin status (admin only)
|
||||||
|
// POST /users/remove-admin
|
||||||
|
router.post('/remove-admin', authenticateJWT, async (req, res) => {
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
const {username} = req.body;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(username)) {
|
||||||
|
return res.status(400).json({error: 'Username is required'});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const adminUser = await db.select().from(users).where(eq(users.id, userId));
|
||||||
|
if (!adminUser || adminUser.length === 0 || !adminUser[0].is_admin) {
|
||||||
|
return res.status(403).json({error: 'Not authorized'});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adminUser[0].username === username) {
|
||||||
|
return res.status(400).json({error: 'Cannot remove your own admin status'});
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUser = await db.select().from(users).where(eq(users.username, username));
|
||||||
|
if (!targetUser || targetUser.length === 0) {
|
||||||
|
return res.status(404).json({error: 'User not found'});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetUser[0].is_admin) {
|
||||||
|
return res.status(400).json({error: 'User is not an admin'});
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(users)
|
||||||
|
.set({is_admin: false})
|
||||||
|
.where(eq(users.username, username));
|
||||||
|
|
||||||
|
logger.success(`Admin status removed from ${username} by ${adminUser[0].username}`);
|
||||||
|
res.json({message: `Admin status removed from ${username}`});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to remove admin status', err);
|
||||||
|
res.status(500).json({error: 'Failed to remove admin status'});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route: Delete user (admin only)
|
||||||
|
// DELETE /users/delete-user
|
||||||
|
router.delete('/delete-user', authenticateJWT, async (req, res) => {
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
const {username} = req.body;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(username)) {
|
||||||
|
return res.status(400).json({error: 'Username is required'});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const adminUser = await db.select().from(users).where(eq(users.id, userId));
|
||||||
|
if (!adminUser || adminUser.length === 0 || !adminUser[0].is_admin) {
|
||||||
|
return res.status(403).json({error: 'Not authorized'});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adminUser[0].username === username) {
|
||||||
|
return res.status(400).json({error: 'Cannot delete your own account'});
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUser = await db.select().from(users).where(eq(users.username, username));
|
||||||
|
if (!targetUser || targetUser.length === 0) {
|
||||||
|
return res.status(404).json({error: 'User not found'});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetUser[0].is_admin) {
|
||||||
|
const adminCount = db.$client.prepare('SELECT COUNT(*) as count FROM users WHERE is_admin = 1').get();
|
||||||
|
if ((adminCount as any)?.count <= 1) {
|
||||||
|
return res.status(403).json({error: 'Cannot delete the last admin user'});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUserId = targetUser[0].id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.$client.prepare('DELETE FROM file_manager_recent WHERE user_id = ?').run(targetUserId);
|
||||||
|
db.$client.prepare('DELETE FROM file_manager_pinned WHERE user_id = ?').run(targetUserId);
|
||||||
|
db.$client.prepare('DELETE FROM file_manager_shortcuts WHERE user_id = ?').run(targetUserId);
|
||||||
|
db.$client.prepare('DELETE FROM ssh_data WHERE user_id = ?').run(targetUserId);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
logger.error(`Cleanup failed for user ${username}:`, cleanupError);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(users).where(eq(users.id, targetUserId));
|
||||||
|
|
||||||
|
logger.success(`User ${username} deleted by admin ${adminUser[0].username}`);
|
||||||
|
res.json({message: `User ${username} deleted successfully`});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to delete user', err);
|
||||||
|
|
||||||
|
if (err && typeof err === 'object' && 'code' in err) {
|
||||||
|
if (err.code === 'SQLITE_CONSTRAINT_FOREIGNKEY') {
|
||||||
|
res.status(400).json({error: 'Cannot delete user: User has associated data that cannot be removed'});
|
||||||
|
} else {
|
||||||
|
res.status(500).json({error: `Database error: ${err.code}`});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
res.status(500).json({error: 'Failed to delete account'});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
1032
src/backend/ssh/file-manager.ts
Normal file
445
src/backend/ssh/server-stats.ts
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import fetch from 'node-fetch';
|
||||||
|
import net from 'net';
|
||||||
|
import cors from 'cors';
|
||||||
|
import {Client, type ConnectConfig} from 'ssh2';
|
||||||
|
|
||||||
|
type HostRecord = {
|
||||||
|
id: number;
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
username?: string;
|
||||||
|
authType?: 'password' | 'key' | string;
|
||||||
|
password?: string | null;
|
||||||
|
key?: string | null;
|
||||||
|
keyPassword?: string | null;
|
||||||
|
keyType?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HostStatus = 'online' | 'offline';
|
||||||
|
|
||||||
|
type StatusEntry = {
|
||||||
|
status: HostStatus;
|
||||||
|
lastChecked: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(cors({
|
||||||
|
origin: '*',
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||||
|
allowedHeaders: ['Content-Type', 'Authorization']
|
||||||
|
}));
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.header('Access-Control-Allow-Origin', '*');
|
||||||
|
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||||
|
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
return res.sendStatus(204);
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
const statsIconSymbol = '📡';
|
||||||
|
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||||
|
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
||||||
|
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#22c55e')(`[${statsIconSymbol}]`)} ${message}`;
|
||||||
|
};
|
||||||
|
const logger = {
|
||||||
|
info: (msg: string): void => {
|
||||||
|
console.log(formatMessage('info', chalk.cyan, msg));
|
||||||
|
},
|
||||||
|
warn: (msg: string): void => {
|
||||||
|
console.warn(formatMessage('warn', chalk.yellow, msg));
|
||||||
|
},
|
||||||
|
error: (msg: string, err?: unknown): void => {
|
||||||
|
console.error(formatMessage('error', chalk.redBright, msg));
|
||||||
|
if (err) console.error(err);
|
||||||
|
},
|
||||||
|
success: (msg: string): void => {
|
||||||
|
console.log(formatMessage('success', chalk.greenBright, msg));
|
||||||
|
},
|
||||||
|
debug: (msg: string): void => {
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
console.debug(formatMessage('debug', chalk.magenta, msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hostStatuses: Map<number, StatusEntry> = new Map();
|
||||||
|
|
||||||
|
async function fetchAllHosts(): Promise<HostRecord[]> {
|
||||||
|
const url = 'http://localhost:8081/ssh/db/host/internal';
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
headers: {'x-internal-request': '1'}
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(`DB service error: ${resp.status} ${resp.statusText}`);
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
const hosts: HostRecord[] = (Array.isArray(data) ? data : []).map((h: any) => ({
|
||||||
|
id: Number(h.id),
|
||||||
|
ip: String(h.ip),
|
||||||
|
port: Number(h.port) || 22,
|
||||||
|
username: h.username,
|
||||||
|
authType: h.authType,
|
||||||
|
password: h.password ?? null,
|
||||||
|
key: h.key ?? null,
|
||||||
|
keyPassword: h.keyPassword ?? null,
|
||||||
|
keyType: h.keyType ?? null,
|
||||||
|
})).filter(h => !!h.id && !!h.ip && !!h.port);
|
||||||
|
return hosts;
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to fetch hosts from database service', err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchHostById(id: number): Promise<HostRecord | undefined> {
|
||||||
|
const all = await fetchAllHosts();
|
||||||
|
return all.find(h => h.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSshConfig(host: HostRecord): ConnectConfig {
|
||||||
|
const base: ConnectConfig = {
|
||||||
|
host: host.ip,
|
||||||
|
port: host.port || 22,
|
||||||
|
username: host.username || 'root',
|
||||||
|
readyTimeout: 10_000,
|
||||||
|
algorithms: {}
|
||||||
|
} as ConnectConfig;
|
||||||
|
|
||||||
|
if (host.authType === 'password') {
|
||||||
|
(base as any).password = host.password || '';
|
||||||
|
} else if (host.authType === 'key') {
|
||||||
|
if (host.key) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withSshConnection<T>(host: HostRecord, fn: (client: Client) => Promise<T>): Promise<T> {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const client = new Client();
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const onError = (err: Error) => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
try {
|
||||||
|
client.end();
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
client.on('ready', async () => {
|
||||||
|
try {
|
||||||
|
const result = await fn(client);
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
try {
|
||||||
|
client.end();
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
onError(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('error', onError);
|
||||||
|
client.on('timeout', () => onError(new Error('SSH connection timeout')));
|
||||||
|
try {
|
||||||
|
client.connect(buildSshConfig(host));
|
||||||
|
} catch (err: any) {
|
||||||
|
onError(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function execCommand(client: Client, command: string): Promise<{
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
code: number | null;
|
||||||
|
}> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
client.exec(command, {pty: false}, (err, stream) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
let exitCode: number | null = null;
|
||||||
|
stream.on('close', (code: number | undefined) => {
|
||||||
|
exitCode = typeof code === 'number' ? code : null;
|
||||||
|
resolve({stdout, stderr, code: exitCode});
|
||||||
|
}).on('data', (data: Buffer) => {
|
||||||
|
stdout += data.toString('utf8');
|
||||||
|
}).stderr.on('data', (data: Buffer) => {
|
||||||
|
stderr += data.toString('utf8');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCpuLine(cpuLine: string): { total: number; idle: number } | undefined {
|
||||||
|
const parts = cpuLine.trim().split(/\s+/);
|
||||||
|
if (parts[0] !== 'cpu') return undefined;
|
||||||
|
const nums = parts.slice(1).map(n => Number(n)).filter(n => Number.isFinite(n));
|
||||||
|
if (nums.length < 4) return undefined;
|
||||||
|
const idle = (nums[3] ?? 0) + (nums[4] ?? 0);
|
||||||
|
const total = nums.reduce((a, b) => a + b, 0);
|
||||||
|
return {total, idle};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFixedNum(n: number | null | undefined, digits = 2): number | null {
|
||||||
|
if (typeof n !== 'number' || !Number.isFinite(n)) return null;
|
||||||
|
return Number(n.toFixed(digits));
|
||||||
|
}
|
||||||
|
|
||||||
|
function kibToGiB(kib: number): number {
|
||||||
|
return kib / (1024 * 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectMetrics(host: HostRecord): Promise<{
|
||||||
|
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 };
|
||||||
|
}> {
|
||||||
|
return withSshConnection(host, async (client) => {
|
||||||
|
let cpuPercent: number | null = null;
|
||||||
|
let cores: number | null = null;
|
||||||
|
let loadTriplet: [number, number, number] | null = null;
|
||||||
|
try {
|
||||||
|
const stat1 = await execCommand(client, 'cat /proc/stat');
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
const stat2 = await execCommand(client, 'cat /proc/stat');
|
||||||
|
const loadAvgOut = await execCommand(client, 'cat /proc/loadavg');
|
||||||
|
const coresOut = await execCommand(client, 'nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo');
|
||||||
|
|
||||||
|
const cpuLine1 = (stat1.stdout.split('\n').find(l => l.startsWith('cpu ')) || '').trim();
|
||||||
|
const cpuLine2 = (stat2.stdout.split('\n').find(l => l.startsWith('cpu ')) || '').trim();
|
||||||
|
const a = parseCpuLine(cpuLine1);
|
||||||
|
const b = parseCpuLine(cpuLine2);
|
||||||
|
if (a && b) {
|
||||||
|
const totalDiff = b.total - a.total;
|
||||||
|
const idleDiff = b.idle - a.idle;
|
||||||
|
const used = totalDiff - idleDiff;
|
||||||
|
if (totalDiff > 0) cpuPercent = Math.max(0, Math.min(100, (used / totalDiff) * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
const laParts = loadAvgOut.stdout.trim().split(/\s+/);
|
||||||
|
if (laParts.length >= 3) {
|
||||||
|
loadTriplet = [Number(laParts[0]), Number(laParts[1]), Number(laParts[2])].map(v => Number.isFinite(v) ? Number(v) : 0) as [number, number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
const coresNum = Number((coresOut.stdout || '').trim());
|
||||||
|
cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null;
|
||||||
|
} catch (e) {
|
||||||
|
cpuPercent = null;
|
||||||
|
cores = null;
|
||||||
|
loadTriplet = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let memPercent: number | null = null;
|
||||||
|
let usedGiB: number | null = null;
|
||||||
|
let totalGiB: number | null = null;
|
||||||
|
try {
|
||||||
|
const memInfo = await execCommand(client, 'cat /proc/meminfo');
|
||||||
|
const lines = memInfo.stdout.split('\n');
|
||||||
|
const getVal = (key: string) => {
|
||||||
|
const line = lines.find(l => l.startsWith(key));
|
||||||
|
if (!line) return null;
|
||||||
|
const m = line.match(/\d+/);
|
||||||
|
return m ? Number(m[0]) : null;
|
||||||
|
};
|
||||||
|
const totalKb = getVal('MemTotal:');
|
||||||
|
const availKb = getVal('MemAvailable:');
|
||||||
|
if (totalKb && availKb && totalKb > 0) {
|
||||||
|
const usedKb = totalKb - availKb;
|
||||||
|
memPercent = Math.max(0, Math.min(100, (usedKb / totalKb) * 100));
|
||||||
|
usedGiB = kibToGiB(usedKb);
|
||||||
|
totalGiB = kibToGiB(totalKb);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
memPercent = null;
|
||||||
|
usedGiB = null;
|
||||||
|
totalGiB = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let diskPercent: number | null = null;
|
||||||
|
let usedHuman: string | null = null;
|
||||||
|
let totalHuman: string | null = null;
|
||||||
|
try {
|
||||||
|
// 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;
|
||||||
|
usedHuman = null;
|
||||||
|
totalHuman = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cpu: {percent: toFixedNum(cpuPercent, 0), cores, load: loadTriplet},
|
||||||
|
memory: {
|
||||||
|
percent: toFixedNum(memPercent, 0),
|
||||||
|
usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null,
|
||||||
|
totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null
|
||||||
|
},
|
||||||
|
disk: {percent: toFixedNum(diskPercent, 0), usedHuman, totalHuman},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function tcpPing(host: string, port: number, timeoutMs = 5000): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const socket = new net.Socket();
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const onDone = (result: boolean) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
try {
|
||||||
|
socket.destroy();
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.setTimeout(timeoutMs);
|
||||||
|
|
||||||
|
socket.once('connect', () => onDone(true));
|
||||||
|
socket.once('timeout', () => onDone(false));
|
||||||
|
socket.once('error', () => onDone(false));
|
||||||
|
socket.connect(port, host);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollStatusesOnce(): Promise<void> {
|
||||||
|
const hosts = await fetchAllHosts();
|
||||||
|
if (hosts.length === 0) {
|
||||||
|
logger.warn('No hosts retrieved for status polling');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
const checks = hosts.map(async (h) => {
|
||||||
|
const isOnline = await tcpPing(h.ip, h.port, 5000);
|
||||||
|
hostStatuses.set(h.id, {status: isOnline ? 'online' : 'offline', lastChecked: now});
|
||||||
|
return isOnline;
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(checks);
|
||||||
|
const onlineCount = results.filter(r => r.status === 'fulfilled' && r.value === true).length;
|
||||||
|
const offlineCount = hosts.length - onlineCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/status', async (req, res) => {
|
||||||
|
if (hostStatuses.size === 0) {
|
||||||
|
await pollStatusesOnce();
|
||||||
|
}
|
||||||
|
const result: Record<number, StatusEntry> = {};
|
||||||
|
for (const [id, entry] of hostStatuses.entries()) {
|
||||||
|
result[id] = entry;
|
||||||
|
}
|
||||||
|
res.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/status/:id', async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
if (!id) {
|
||||||
|
return res.status(400).json({error: 'Invalid id'});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hostStatuses.has(id)) {
|
||||||
|
await pollStatusesOnce();
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = hostStatuses.get(id);
|
||||||
|
if (!entry) {
|
||||||
|
return res.status(404).json({error: 'Host not found'});
|
||||||
|
}
|
||||||
|
res.json(entry);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/refresh', async (req, res) => {
|
||||||
|
await pollStatusesOnce();
|
||||||
|
res.json({message: 'Refreshed'});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/metrics/:id', async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
if (!id) {
|
||||||
|
return res.status(400).json({error: 'Invalid id'});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const host = await fetchHostById(id);
|
||||||
|
if (!host) {
|
||||||
|
return res.status(404).json({error: 'Host not found'});
|
||||||
|
}
|
||||||
|
const metrics = await collectMetrics(host);
|
||||||
|
res.json({...metrics, lastChecked: new Date().toISOString()});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to collect metrics', err);
|
||||||
|
return res.json({
|
||||||
|
cpu: {percent: null, cores: null, load: null},
|
||||||
|
memory: {percent: null, usedGiB: null, totalGiB: null},
|
||||||
|
disk: {percent: null, usedHuman: null, totalHuman: null},
|
||||||
|
lastChecked: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const PORT = 8085;
|
||||||
|
app.listen(PORT, async () => {
|
||||||
|
try {
|
||||||
|
await pollStatusesOnce();
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Initial poll failed', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -4,6 +4,9 @@ import chalk from 'chalk';
|
|||||||
|
|
||||||
const wss = new WebSocketServer({port: 8082});
|
const wss = new WebSocketServer({port: 8082});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const sshIconSymbol = '🖥️';
|
const sshIconSymbol = '🖥️';
|
||||||
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||||
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
||||||
@@ -30,16 +33,22 @@ const logger = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
wss.on('connection', (ws: WebSocket) => {
|
wss.on('connection', (ws: WebSocket) => {
|
||||||
let sshConn: Client | null = null;
|
let sshConn: Client | null = null;
|
||||||
let sshStream: ClientChannel | null = null;
|
let sshStream: ClientChannel | null = null;
|
||||||
let pingInterval: NodeJS.Timeout | null = null;
|
let pingInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
ws.on('close', () => {
|
ws.on('close', () => {
|
||||||
cleanupSSH();
|
cleanupSSH();
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on('message', (msg: RawData) => {
|
ws.on('message', (msg: RawData) => {
|
||||||
|
|
||||||
|
|
||||||
let parsed: any;
|
let parsed: any;
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(msg.toString());
|
parsed = JSON.parse(msg.toString());
|
||||||
@@ -132,34 +141,13 @@ wss.on('connection', (ws: WebSocket) => {
|
|||||||
|
|
||||||
sshConn.on('ready', () => {
|
sshConn.on('ready', () => {
|
||||||
clearTimeout(connectionTimeout);
|
clearTimeout(connectionTimeout);
|
||||||
const pseudoTtyOpts: PseudoTtyOptions = {
|
|
||||||
term: 'xterm-256color',
|
|
||||||
cols,
|
|
||||||
rows,
|
|
||||||
modes: {
|
|
||||||
ECHO: 1,
|
|
||||||
ECHOCTL: 0,
|
|
||||||
ICANON: 1,
|
|
||||||
ISIG: 1,
|
|
||||||
ICRNL: 1,
|
|
||||||
IXON: 1,
|
|
||||||
IXOFF: 0,
|
|
||||||
ISTRIP: 0,
|
|
||||||
OPOST: 1,
|
|
||||||
ONLCR: 1,
|
|
||||||
OCRNL: 0,
|
|
||||||
ONOCR: 0,
|
|
||||||
ONLRET: 0,
|
|
||||||
CS7: 0,
|
|
||||||
CS8: 1,
|
|
||||||
PARENB: 0,
|
|
||||||
PARODD: 0,
|
|
||||||
TTY_OP_ISPEED: 38400,
|
|
||||||
TTY_OP_OSPEED: 38400,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
sshConn!.shell(pseudoTtyOpts, (err, stream) => {
|
|
||||||
|
sshConn!.shell({
|
||||||
|
rows: data.rows,
|
||||||
|
cols: data.cols,
|
||||||
|
term: 'xterm-256color'
|
||||||
|
} as PseudoTtyOptions, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
logger.error('Shell error: ' + err.message);
|
logger.error('Shell error: ' + err.message);
|
||||||
ws.send(JSON.stringify({type: 'error', message: 'Shell error: ' + err.message}));
|
ws.send(JSON.stringify({type: 'error', message: 'Shell error: ' + err.message}));
|
||||||
@@ -168,34 +156,18 @@ wss.on('connection', (ws: WebSocket) => {
|
|||||||
|
|
||||||
sshStream = stream;
|
sshStream = stream;
|
||||||
|
|
||||||
stream.on('data', (chunk: Buffer) => {
|
stream.on('data', (data: Buffer) => {
|
||||||
let data: string;
|
ws.send(JSON.stringify({type: 'data', data: data.toString()}));
|
||||||
try {
|
|
||||||
data = chunk.toString('utf8');
|
|
||||||
} catch (e) {
|
|
||||||
data = chunk.toString('binary');
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.send(JSON.stringify({type: 'data', data}));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
stream.on('close', () => {
|
stream.on('close', () => {
|
||||||
cleanupSSH(connectionTimeout);
|
|
||||||
|
ws.send(JSON.stringify({type: 'disconnected', message: 'Connection lost'}));
|
||||||
});
|
});
|
||||||
|
|
||||||
stream.on('error', (err: Error) => {
|
stream.on('error', (err: Error) => {
|
||||||
logger.error('SSH stream error: ' + err.message);
|
logger.error('SSH stream error: ' + err.message);
|
||||||
|
|
||||||
const isConnectionError = err.message.includes('ECONNRESET') ||
|
|
||||||
err.message.includes('EPIPE') ||
|
|
||||||
err.message.includes('ENOTCONN') ||
|
|
||||||
err.message.includes('ETIMEDOUT');
|
|
||||||
|
|
||||||
if (isConnectionError) {
|
|
||||||
ws.send(JSON.stringify({type: 'disconnected', message: 'Connection lost'}));
|
|
||||||
} else {
|
|
||||||
ws.send(JSON.stringify({type: 'error', message: 'SSH stream error: ' + err.message}));
|
ws.send(JSON.stringify({type: 'error', message: 'SSH stream error: ' + err.message}));
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setupPingInterval();
|
setupPingInterval();
|
||||||
@@ -233,9 +205,12 @@ wss.on('connection', (ws: WebSocket) => {
|
|||||||
|
|
||||||
sshConn.on('close', () => {
|
sshConn.on('close', () => {
|
||||||
clearTimeout(connectionTimeout);
|
clearTimeout(connectionTimeout);
|
||||||
|
|
||||||
cleanupSSH(connectionTimeout);
|
cleanupSSH(connectionTimeout);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const connectConfig: any = {
|
const connectConfig: any = {
|
||||||
host: ip,
|
host: ip,
|
||||||
port,
|
port,
|
||||||
@@ -245,6 +220,7 @@ wss.on('connection', (ws: WebSocket) => {
|
|||||||
readyTimeout: 10000,
|
readyTimeout: 10000,
|
||||||
tcpKeepAlive: true,
|
tcpKeepAlive: true,
|
||||||
tcpKeepAliveInitialDelay: 30000,
|
tcpKeepAliveInitialDelay: 30000,
|
||||||
|
|
||||||
env: {
|
env: {
|
||||||
TERM: 'xterm-256color',
|
TERM: 'xterm-256color',
|
||||||
LANG: 'en_US.UTF-8',
|
LANG: 'en_US.UTF-8',
|
||||||
@@ -294,13 +270,27 @@ wss.on('connection', (ws: WebSocket) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (authType === 'key' && key) {
|
if (authType === 'key' && key) {
|
||||||
connectConfig.privateKey = key;
|
try {
|
||||||
|
if (!key.includes('-----BEGIN') || !key.includes('-----END')) {
|
||||||
|
throw new Error('Invalid private key format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanKey = key.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||||
|
|
||||||
|
connectConfig.privateKey = Buffer.from(cleanKey, 'utf8');
|
||||||
|
|
||||||
if (keyPassword) {
|
if (keyPassword) {
|
||||||
connectConfig.passphrase = keyPassword;
|
connectConfig.passphrase = keyPassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (keyType && keyType !== 'auto') {
|
if (keyType && keyType !== 'auto') {
|
||||||
connectConfig.privateKeyType = keyType;
|
connectConfig.privateKeyType = keyType;
|
||||||
}
|
}
|
||||||
|
} catch (keyError) {
|
||||||
|
logger.error('SSH key format error: ' + keyError.message);
|
||||||
|
ws.send(JSON.stringify({type: 'error', message: 'SSH key format error: Invalid private key format'}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
} else if (authType === 'key') {
|
} else if (authType === 'key') {
|
||||||
logger.error('SSH key authentication requested but no key provided');
|
logger.error('SSH key authentication requested but no key provided');
|
||||||
ws.send(JSON.stringify({type: 'error', message: 'SSH key authentication requested but no key provided'}));
|
ws.send(JSON.stringify({type: 'error', message: 'SSH key authentication requested but no key provided'}));
|
||||||
@@ -360,4 +350,6 @@ wss.on('connection', (ws: WebSocket) => {
|
|||||||
}
|
}
|
||||||
}, 60000);
|
}, 60000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
@@ -78,7 +78,7 @@ interface SSHHost {
|
|||||||
keyType?: string;
|
keyType?: string;
|
||||||
enableTerminal: boolean;
|
enableTerminal: boolean;
|
||||||
enableTunnel: boolean;
|
enableTunnel: boolean;
|
||||||
enableConfigEditor: boolean;
|
enableFileManager: boolean;
|
||||||
defaultPath: string;
|
defaultPath: string;
|
||||||
tunnelConnections: TunnelConnection[];
|
tunnelConnections: TunnelConnection[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -197,7 +197,8 @@ function classifyError(errorMessage: string): ErrorType {
|
|||||||
|
|
||||||
if (message.includes("connect etimedout") ||
|
if (message.includes("connect etimedout") ||
|
||||||
message.includes("timeout") ||
|
message.includes("timeout") ||
|
||||||
message.includes("timed out")) {
|
message.includes("timed out") ||
|
||||||
|
message.includes("keepalive timeout")) {
|
||||||
return ERROR_TYPES.TIMEOUT;
|
return ERROR_TYPES.TIMEOUT;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +268,8 @@ function cleanupTunnelResources(tunnelName: string): void {
|
|||||||
tunnelName,
|
tunnelName,
|
||||||
`${tunnelName}_confirm`,
|
`${tunnelName}_confirm`,
|
||||||
`${tunnelName}_retry`,
|
`${tunnelName}_retry`,
|
||||||
`${tunnelName}_verify_retry`
|
`${tunnelName}_verify_retry`,
|
||||||
|
`${tunnelName}_ping`
|
||||||
];
|
];
|
||||||
|
|
||||||
timerKeys.forEach(key => {
|
timerKeys.forEach(key => {
|
||||||
@@ -302,7 +304,7 @@ function resetRetryState(tunnelName: string): void {
|
|||||||
countdownIntervals.delete(tunnelName);
|
countdownIntervals.delete(tunnelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
['', '_confirm', '_retry', '_verify_retry'].forEach(suffix => {
|
['', '_confirm', '_retry', '_verify_retry', '_ping'].forEach(suffix => {
|
||||||
const timerKey = `${tunnelName}${suffix}`;
|
const timerKey = `${tunnelName}${suffix}`;
|
||||||
if (verificationTimers.has(timerKey)) {
|
if (verificationTimers.has(timerKey)) {
|
||||||
clearTimeout(verificationTimers.get(timerKey)!);
|
clearTimeout(verificationTimers.get(timerKey)!);
|
||||||
@@ -353,7 +355,8 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
|
|||||||
const maxRetries = tunnelConfig.maxRetries || 3;
|
const maxRetries = tunnelConfig.maxRetries || 3;
|
||||||
const retryInterval = tunnelConfig.retryInterval || 5000;
|
const retryInterval = tunnelConfig.retryInterval || 5000;
|
||||||
|
|
||||||
let retryCount = (retryCounters.get(tunnelName) || 0) + 1;
|
let retryCount = retryCounters.get(tunnelName) || 0;
|
||||||
|
retryCount = retryCount + 1;
|
||||||
|
|
||||||
if (retryCount > maxRetries) {
|
if (retryCount > maxRetries) {
|
||||||
logger.error(`All ${maxRetries} retries failed for ${tunnelName}`);
|
logger.error(`All ${maxRetries} retries failed for ${tunnelName}`);
|
||||||
@@ -420,7 +423,6 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
|
|||||||
|
|
||||||
if (!manualDisconnects.has(tunnelName)) {
|
if (!manualDisconnects.has(tunnelName)) {
|
||||||
activeTunnels.delete(tunnelName);
|
activeTunnels.delete(tunnelName);
|
||||||
|
|
||||||
connectSSHTunnel(tunnelConfig, retryCount);
|
connectSSHTunnel(tunnelConfig, retryCount);
|
||||||
}
|
}
|
||||||
}, retryInterval);
|
}, retryInterval);
|
||||||
@@ -438,264 +440,43 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
|
|||||||
}
|
}
|
||||||
|
|
||||||
function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig, isPeriodic = false): void {
|
function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig, isPeriodic = false): void {
|
||||||
if (manualDisconnects.has(tunnelName) || !activeTunnels.has(tunnelName)) {
|
if (isPeriodic) {
|
||||||
return;
|
if (!activeTunnels.has(tunnelName)) {
|
||||||
}
|
|
||||||
|
|
||||||
if (tunnelVerifications.has(tunnelName)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const conn = activeTunnels.get(tunnelName);
|
|
||||||
if (!conn) return;
|
|
||||||
|
|
||||||
broadcastTunnelStatus(tunnelName, {
|
broadcastTunnelStatus(tunnelName, {
|
||||||
connected: false,
|
connected: false,
|
||||||
status: CONNECTION_STATES.VERIFYING
|
status: CONNECTION_STATES.DISCONNECTED,
|
||||||
});
|
reason: 'Tunnel connection lost'
|
||||||
|
|
||||||
const verificationConn = new Client();
|
|
||||||
tunnelVerifications.set(tunnelName, {
|
|
||||||
conn: verificationConn,
|
|
||||||
timeout: setTimeout(() => {
|
|
||||||
logger.error(`Verification timeout for '${tunnelName}'`);
|
|
||||||
cleanupVerification(false, "Verification timeout");
|
|
||||||
}, 10000)
|
|
||||||
});
|
|
||||||
|
|
||||||
function cleanupVerification(isSuccessful: boolean, failureReason = "Unknown verification failure") {
|
|
||||||
const verification = tunnelVerifications.get(tunnelName);
|
|
||||||
if (verification) {
|
|
||||||
clearTimeout(verification.timeout);
|
|
||||||
try {
|
|
||||||
verification.conn.end();
|
|
||||||
} catch (e) {
|
|
||||||
}
|
|
||||||
tunnelVerifications.delete(tunnelName);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSuccessful) {
|
|
||||||
broadcastTunnelStatus(tunnelName, {
|
|
||||||
connected: true,
|
|
||||||
status: CONNECTION_STATES.CONNECTED
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!isPeriodic) {
|
|
||||||
setupPingInterval(tunnelName, tunnelConfig);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.warn(`Verification failed for '${tunnelName}': ${failureReason}`);
|
|
||||||
|
|
||||||
if (failureReason.includes('command failed') || failureReason.includes('connection error') || failureReason.includes('timeout')) {
|
|
||||||
if (!manualDisconnects.has(tunnelName)) {
|
|
||||||
broadcastTunnelStatus(tunnelName, {
|
|
||||||
connected: false,
|
|
||||||
status: CONNECTION_STATES.FAILED,
|
|
||||||
reason: failureReason
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
activeTunnels.delete(tunnelName);
|
|
||||||
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
|
|
||||||
} else {
|
|
||||||
logger.info(`Assuming tunnel '${tunnelName}' is working despite verification warning: ${failureReason}`);
|
|
||||||
cleanupVerification(true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function attemptVerification() {
|
|
||||||
const testCmd = `timeout 3 bash -c 'nc -z ${tunnelConfig.endpointIP} ${tunnelConfig.endpointPort}'`;
|
|
||||||
|
|
||||||
verificationConn.exec(testCmd, (err, stream) => {
|
|
||||||
if (err) {
|
|
||||||
logger.error(`Verification command failed for '${tunnelName}': ${err.message}`);
|
|
||||||
cleanupVerification(false, `Verification command failed: ${err.message}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let output = '';
|
|
||||||
let errorOutput = '';
|
|
||||||
|
|
||||||
stream.on('data', (data: Buffer) => {
|
|
||||||
output += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.stderr?.on('data', (data: Buffer) => {
|
|
||||||
errorOutput += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on('close', (code: number) => {
|
|
||||||
if (code === 0) {
|
|
||||||
cleanupVerification(true);
|
|
||||||
} else {
|
|
||||||
const isTimeout = errorOutput.includes('timeout') || errorOutput.includes('Connection timed out');
|
|
||||||
const isConnectionRefused = errorOutput.includes('Connection refused') || errorOutput.includes('No route to host');
|
|
||||||
|
|
||||||
let failureReason = `Cannot connect to ${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort}`;
|
|
||||||
if (isTimeout) {
|
|
||||||
failureReason = `Tunnel verification timeout - cannot reach ${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort}`;
|
|
||||||
} else if (isConnectionRefused) {
|
|
||||||
failureReason = `Connection refused to ${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort} - tunnel may not be established`;
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanupVerification(false, failureReason);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on('error', (err: Error) => {
|
|
||||||
logger.error(`Verification stream error for '${tunnelName}': ${err.message}`);
|
|
||||||
cleanupVerification(false, `Verification stream error: ${err.message}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
verificationConn.on('ready', () => {
|
|
||||||
setTimeout(() => {
|
|
||||||
attemptVerification();
|
|
||||||
}, 2000);
|
|
||||||
});
|
|
||||||
|
|
||||||
verificationConn.on('error', (err: Error) => {
|
|
||||||
cleanupVerification(false, `Verification connection error: ${err.message}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
verificationConn.on('close', () => {
|
|
||||||
if (tunnelVerifications.has(tunnelName)) {
|
|
||||||
cleanupVerification(false, "Verification connection closed");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const connOptions: any = {
|
|
||||||
host: tunnelConfig.sourceIP,
|
|
||||||
port: tunnelConfig.sourceSSHPort,
|
|
||||||
username: tunnelConfig.sourceUsername,
|
|
||||||
readyTimeout: 10000,
|
|
||||||
keepaliveInterval: 30000,
|
|
||||||
keepaliveCountMax: 3,
|
|
||||||
tcpKeepAlive: true,
|
|
||||||
tcpKeepAliveInitialDelay: 30000,
|
|
||||||
algorithms: {
|
|
||||||
kex: [
|
|
||||||
'diffie-hellman-group14-sha256',
|
|
||||||
'diffie-hellman-group14-sha1',
|
|
||||||
'diffie-hellman-group1-sha1',
|
|
||||||
'diffie-hellman-group-exchange-sha256',
|
|
||||||
'diffie-hellman-group-exchange-sha1',
|
|
||||||
'ecdh-sha2-nistp256',
|
|
||||||
'ecdh-sha2-nistp384',
|
|
||||||
'ecdh-sha2-nistp521'
|
|
||||||
],
|
|
||||||
cipher: [
|
|
||||||
'aes128-ctr',
|
|
||||||
'aes192-ctr',
|
|
||||||
'aes256-ctr',
|
|
||||||
'aes128-gcm@openssh.com',
|
|
||||||
'aes256-gcm@openssh.com',
|
|
||||||
'aes128-cbc',
|
|
||||||
'aes192-cbc',
|
|
||||||
'aes256-cbc',
|
|
||||||
'3des-cbc'
|
|
||||||
],
|
|
||||||
hmac: [
|
|
||||||
'hmac-sha2-256',
|
|
||||||
'hmac-sha2-512',
|
|
||||||
'hmac-sha1',
|
|
||||||
'hmac-md5'
|
|
||||||
],
|
|
||||||
compress: [
|
|
||||||
'none',
|
|
||||||
'zlib@openssh.com',
|
|
||||||
'zlib'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
|
|
||||||
connOptions.privateKey = tunnelConfig.sourceSSHKey;
|
|
||||||
if (tunnelConfig.sourceKeyPassword) {
|
|
||||||
connOptions.passphrase = tunnelConfig.sourceKeyPassword;
|
|
||||||
}
|
|
||||||
if (tunnelConfig.sourceKeyType && tunnelConfig.sourceKeyType !== 'auto') {
|
|
||||||
connOptions.privateKeyType = tunnelConfig.sourceKeyType;
|
|
||||||
}
|
|
||||||
} else if (tunnelConfig.sourceAuthMethod === "key") {
|
|
||||||
logger.error(`SSH key authentication requested but no key provided for tunnel '${tunnelName}'`);
|
|
||||||
broadcastTunnelStatus(tunnelName, {
|
|
||||||
connected: false,
|
|
||||||
status: CONNECTION_STATES.FAILED,
|
|
||||||
reason: "SSH key authentication requested but no key provided"
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
connOptions.password = tunnelConfig.sourcePassword;
|
|
||||||
}
|
|
||||||
|
|
||||||
verificationConn.connect(connOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupPingInterval(tunnelName: string, tunnelConfig: TunnelConfig): void {
|
function setupPingInterval(tunnelName: string, tunnelConfig: TunnelConfig): void {
|
||||||
|
const pingKey = `${tunnelName}_ping`;
|
||||||
|
if (verificationTimers.has(pingKey)) {
|
||||||
|
clearInterval(verificationTimers.get(pingKey)!);
|
||||||
|
verificationTimers.delete(pingKey);
|
||||||
|
}
|
||||||
|
|
||||||
const pingInterval = setInterval(() => {
|
const pingInterval = setInterval(() => {
|
||||||
if (!activeTunnels.has(tunnelName) || manualDisconnects.has(tunnelName)) {
|
const currentStatus = connectionStatus.get(tunnelName);
|
||||||
clearInterval(pingInterval);
|
if (currentStatus?.status === CONNECTION_STATES.CONNECTED) {
|
||||||
return;
|
if (!activeTunnels.has(tunnelName)) {
|
||||||
}
|
|
||||||
|
|
||||||
const conn = activeTunnels.get(tunnelName);
|
|
||||||
if (!conn) {
|
|
||||||
clearInterval(pingInterval);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
conn.exec('echo "ping"', (err, stream) => {
|
|
||||||
if (err) {
|
|
||||||
clearInterval(pingInterval);
|
|
||||||
|
|
||||||
if (!manualDisconnects.has(tunnelName)) {
|
|
||||||
broadcastTunnelStatus(tunnelName, {
|
broadcastTunnelStatus(tunnelName, {
|
||||||
connected: false,
|
connected: false,
|
||||||
status: CONNECTION_STATES.UNSTABLE,
|
status: CONNECTION_STATES.DISCONNECTED,
|
||||||
reason: "Ping failed"
|
reason: 'Tunnel connection lost'
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
activeTunnels.delete(tunnelName);
|
|
||||||
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
stream.on('close', (code: number) => {
|
|
||||||
if (code !== 0) {
|
|
||||||
clearInterval(pingInterval);
|
clearInterval(pingInterval);
|
||||||
|
verificationTimers.delete(pingKey);
|
||||||
if (!manualDisconnects.has(tunnelName)) {
|
|
||||||
broadcastTunnelStatus(tunnelName, {
|
|
||||||
connected: false,
|
|
||||||
status: CONNECTION_STATES.UNSTABLE,
|
|
||||||
reason: "Ping command failed"
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
activeTunnels.delete(tunnelName);
|
|
||||||
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on('error', (err: Error) => {
|
|
||||||
clearInterval(pingInterval);
|
clearInterval(pingInterval);
|
||||||
|
verificationTimers.delete(pingKey);
|
||||||
if (!manualDisconnects.has(tunnelName)) {
|
|
||||||
broadcastTunnelStatus(tunnelName, {
|
|
||||||
connected: false,
|
|
||||||
status: CONNECTION_STATES.UNSTABLE,
|
|
||||||
reason: "Ping stream error"
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
}, 120000);
|
||||||
|
|
||||||
activeTunnels.delete(tunnelName);
|
verificationTimers.set(pingKey, pingInterval);
|
||||||
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, 60000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||||
@@ -751,7 +532,7 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
|
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 15000);
|
}, 60000);
|
||||||
|
|
||||||
conn.on("error", (err) => {
|
conn.on("error", (err) => {
|
||||||
clearTimeout(connectionTimeout);
|
clearTimeout(connectionTimeout);
|
||||||
@@ -779,6 +560,8 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
errorType === ERROR_TYPES.PERMISSION ||
|
errorType === ERROR_TYPES.PERMISSION ||
|
||||||
manualDisconnects.has(tunnelName);
|
manualDisconnects.has(tunnelName);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
handleDisconnect(tunnelName, tunnelConfig, !shouldNotRetry);
|
handleDisconnect(tunnelName, tunnelConfig, !shouldNotRetry);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -841,7 +624,11 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!manualDisconnects.has(tunnelName) && activeTunnels.has(tunnelName)) {
|
if (!manualDisconnects.has(tunnelName) && activeTunnels.has(tunnelName)) {
|
||||||
verifyTunnelConnection(tunnelName, tunnelConfig, false);
|
broadcastTunnelStatus(tunnelName, {
|
||||||
|
connected: true,
|
||||||
|
status: CONNECTION_STATES.CONNECTED
|
||||||
|
});
|
||||||
|
setupPingInterval(tunnelName, tunnelConfig);
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
|
||||||
@@ -901,7 +688,6 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
|
|
||||||
stream.stderr.on("data", (data) => {
|
stream.stderr.on("data", (data) => {
|
||||||
const errorMsg = data.toString().trim();
|
const errorMsg = data.toString().trim();
|
||||||
logger.debug(`Tunnel stderr for '${tunnelName}': ${errorMsg}`);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -912,9 +698,9 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
username: tunnelConfig.sourceUsername,
|
username: tunnelConfig.sourceUsername,
|
||||||
keepaliveInterval: 30000,
|
keepaliveInterval: 30000,
|
||||||
keepaliveCountMax: 3,
|
keepaliveCountMax: 3,
|
||||||
readyTimeout: 10000,
|
readyTimeout: 60000,
|
||||||
tcpKeepAlive: true,
|
tcpKeepAlive: true,
|
||||||
tcpKeepAliveInitialDelay: 30000,
|
tcpKeepAliveInitialDelay: 15000,
|
||||||
algorithms: {
|
algorithms: {
|
||||||
kex: [
|
kex: [
|
||||||
'diffie-hellman-group14-sha256',
|
'diffie-hellman-group14-sha256',
|
||||||
@@ -952,8 +738,8 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
|
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
|
||||||
if (!tunnelConfig.sourceSSHKey.includes('-----BEGIN')) {
|
if (!tunnelConfig.sourceSSHKey.includes('-----BEGIN') || !tunnelConfig.sourceSSHKey.includes('-----END')) {
|
||||||
logger.error(`Invalid SSH key format for tunnel '${tunnelName}'. Key should start with '-----BEGIN'`);
|
logger.error(`Invalid SSH key format for tunnel '${tunnelName}'. Key should contain both BEGIN and END markers`);
|
||||||
broadcastTunnelStatus(tunnelName, {
|
broadcastTunnelStatus(tunnelName, {
|
||||||
connected: false,
|
connected: false,
|
||||||
status: CONNECTION_STATES.FAILED,
|
status: CONNECTION_STATES.FAILED,
|
||||||
@@ -962,7 +748,8 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
connOptions.privateKey = tunnelConfig.sourceSSHKey;
|
const cleanKey = tunnelConfig.sourceSSHKey.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||||
|
connOptions.privateKey = Buffer.from(cleanKey, 'utf8');
|
||||||
if (tunnelConfig.sourceKeyPassword) {
|
if (tunnelConfig.sourceKeyPassword) {
|
||||||
connOptions.passphrase = tunnelConfig.sourceKeyPassword;
|
connOptions.passphrase = tunnelConfig.sourceKeyPassword;
|
||||||
}
|
}
|
||||||
@@ -981,14 +768,8 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
connOptions.password = tunnelConfig.sourcePassword;
|
connOptions.password = tunnelConfig.sourcePassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
const testSocket = new net.Socket();
|
const finalStatus = connectionStatus.get(tunnelName);
|
||||||
testSocket.setTimeout(5000);
|
if (!finalStatus || finalStatus.status !== CONNECTION_STATES.WAITING) {
|
||||||
|
|
||||||
testSocket.on('connect', () => {
|
|
||||||
testSocket.destroy();
|
|
||||||
|
|
||||||
const currentStatus = connectionStatus.get(tunnelName);
|
|
||||||
if (!currentStatus || currentStatus.status !== CONNECTION_STATES.WAITING) {
|
|
||||||
broadcastTunnelStatus(tunnelName, {
|
broadcastTunnelStatus(tunnelName, {
|
||||||
connected: false,
|
connected: false,
|
||||||
status: CONNECTION_STATES.CONNECTING,
|
status: CONNECTION_STATES.CONNECTING,
|
||||||
@@ -997,27 +778,6 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
conn.connect(connOptions);
|
conn.connect(connOptions);
|
||||||
});
|
|
||||||
|
|
||||||
testSocket.on('timeout', () => {
|
|
||||||
testSocket.destroy();
|
|
||||||
broadcastTunnelStatus(tunnelName, {
|
|
||||||
connected: false,
|
|
||||||
status: CONNECTION_STATES.FAILED,
|
|
||||||
reason: "Network connectivity test failed - server not reachable"
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
testSocket.on('error', (err: any) => {
|
|
||||||
testSocket.destroy();
|
|
||||||
broadcastTunnelStatus(tunnelName, {
|
|
||||||
connected: false,
|
|
||||||
status: CONNECTION_STATES.FAILED,
|
|
||||||
reason: `Network connectivity test failed - ${err.message}`
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
testSocket.connect(tunnelConfig.sourceSSHPort, tunnelConfig.sourceIP);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string, callback: (err?: Error) => void) {
|
function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string, callback: (err?: Error) => void) {
|
||||||
@@ -1029,9 +789,9 @@ function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string
|
|||||||
username: tunnelConfig.sourceUsername,
|
username: tunnelConfig.sourceUsername,
|
||||||
keepaliveInterval: 30000,
|
keepaliveInterval: 30000,
|
||||||
keepaliveCountMax: 3,
|
keepaliveCountMax: 3,
|
||||||
readyTimeout: 10000,
|
readyTimeout: 60000,
|
||||||
tcpKeepAlive: true,
|
tcpKeepAlive: true,
|
||||||
tcpKeepAliveInitialDelay: 30000,
|
tcpKeepAliveInitialDelay: 15000,
|
||||||
algorithms: {
|
algorithms: {
|
||||||
kex: [
|
kex: [
|
||||||
'diffie-hellman-group14-sha256',
|
'diffie-hellman-group14-sha256',
|
||||||
@@ -1068,7 +828,13 @@ function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
|
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
|
||||||
connOptions.privateKey = tunnelConfig.sourceSSHKey;
|
if (!tunnelConfig.sourceSSHKey.includes('-----BEGIN') || !tunnelConfig.sourceSSHKey.includes('-----END')) {
|
||||||
|
callback(new Error('Invalid SSH key format'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanKey = tunnelConfig.sourceSSHKey.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||||
|
connOptions.privateKey = Buffer.from(cleanKey, 'utf8');
|
||||||
if (tunnelConfig.sourceKeyPassword) {
|
if (tunnelConfig.sourceKeyPassword) {
|
||||||
connOptions.passphrase = tunnelConfig.sourceKeyPassword;
|
connOptions.passphrase = tunnelConfig.sourceKeyPassword;
|
||||||
}
|
}
|
||||||
@@ -2,9 +2,10 @@
|
|||||||
// node ./dist/backend/starter.js
|
// node ./dist/backend/starter.js
|
||||||
|
|
||||||
import './database/database.js'
|
import './database/database.js'
|
||||||
import './ssh/ssh.js';
|
import './ssh/terminal.js';
|
||||||
import './ssh_tunnel/ssh_tunnel.js';
|
import './ssh/tunnel.js';
|
||||||
import './config_editor/config_editor.js';
|
import './ssh/file-manager.js';
|
||||||
|
import './ssh/server-stats.js';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
|
|
||||||
const fixedIconSymbol = '🚀';
|
const fixedIconSymbol = '🚀';
|
||||||
|
|||||||
58
src/components/ui/button-group.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { Children, ReactElement, cloneElement, isValidElement } from 'react';
|
||||||
|
|
||||||
|
import { ButtonProps } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface ButtonGroupProps {
|
||||||
|
className?: string;
|
||||||
|
orientation?: 'horizontal' | 'vertical';
|
||||||
|
children: ReactElement<ButtonProps>[] | React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ButtonGroup = ({
|
||||||
|
className,
|
||||||
|
orientation = 'horizontal',
|
||||||
|
children,
|
||||||
|
}: ButtonGroupProps) => {
|
||||||
|
const isHorizontal = orientation === 'horizontal';
|
||||||
|
const isVertical = orientation === 'vertical';
|
||||||
|
|
||||||
|
// Normalize and filter only valid React elements
|
||||||
|
const childArray = Children.toArray(children).filter((child): child is ReactElement<ButtonProps> =>
|
||||||
|
isValidElement(child)
|
||||||
|
);
|
||||||
|
const totalButtons = childArray.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex',
|
||||||
|
{
|
||||||
|
'flex-col': isVertical,
|
||||||
|
'w-fit': isVertical,
|
||||||
|
},
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{childArray.map((child, index) => {
|
||||||
|
const isFirst = index === 0;
|
||||||
|
const isLast = index === totalButtons - 1;
|
||||||
|
|
||||||
|
return cloneElement(child, {
|
||||||
|
className: cn(
|
||||||
|
{
|
||||||
|
'rounded-l-none': isHorizontal && !isFirst,
|
||||||
|
'rounded-r-none': isHorizontal && !isLast,
|
||||||
|
'border-l-0': isHorizontal && !isFirst,
|
||||||
|
|
||||||
|
'rounded-t-none': isVertical && !isFirst,
|
||||||
|
'rounded-b-none': isVertical && !isLast,
|
||||||
|
'border-t-0': isVertical && !isFirst,
|
||||||
|
},
|
||||||
|
child.props.className
|
||||||
|
),
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
29
src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Progress({
|
||||||
|
className,
|
||||||
|
value,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
data-slot="progress"
|
||||||
|
className={cn(
|
||||||
|
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
data-slot="progress-indicator"
|
||||||
|
className="bg-primary h-full w-full flex-1 transition-all"
|
||||||
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Progress }
|
||||||
62
src/components/ui/shadcn-io/status/index.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import type { ComponentProps, HTMLAttributes } from 'react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export type StatusProps = ComponentProps<typeof Badge> & {
|
||||||
|
status: 'online' | 'offline' | 'maintenance' | 'degraded';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Status = ({ className, status, ...props }: StatusProps) => (
|
||||||
|
<Badge
|
||||||
|
className={cn('flex items-center gap-2', 'group', status, className)}
|
||||||
|
variant="secondary"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export type StatusIndicatorProps = HTMLAttributes<HTMLSpanElement>;
|
||||||
|
|
||||||
|
export const StatusIndicator = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: StatusIndicatorProps) => (
|
||||||
|
<span className="relative flex h-2 w-2" {...props}>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'absolute inline-flex h-full w-full animate-ping rounded-full opacity-75',
|
||||||
|
'group-[.online]:bg-emerald-500',
|
||||||
|
'group-[.offline]:bg-red-500',
|
||||||
|
'group-[.maintenance]:bg-blue-500',
|
||||||
|
'group-[.degraded]:bg-amber-500'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'relative inline-flex h-2 w-2 rounded-full',
|
||||||
|
'group-[.online]:bg-emerald-500',
|
||||||
|
'group-[.offline]:bg-red-500',
|
||||||
|
'group-[.maintenance]:bg-blue-500',
|
||||||
|
'group-[.degraded]:bg-amber-500'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
export type StatusLabelProps = HTMLAttributes<HTMLSpanElement>;
|
||||||
|
|
||||||
|
export const StatusLabel = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: StatusLabelProps) => (
|
||||||
|
<span className={cn('text-muted-foreground', className)} {...props}>
|
||||||
|
{children ?? (
|
||||||
|
<>
|
||||||
|
<span className="hidden group-[.online]:block">Online</span>
|
||||||
|
<span className="hidden group-[.offline]:block">Offline</span>
|
||||||
|
<span className="hidden group-[.maintenance]:block">Maintenance</span>
|
||||||
|
<span className="hidden group-[.degraded]:block">Degraded</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
@@ -34,7 +34,7 @@ function SheetOverlay({
|
|||||||
<SheetPrimitive.Overlay
|
<SheetPrimitive.Overlay
|
||||||
data-slot="sheet-overlay"
|
data-slot="sheet-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:pointer-events-none",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -56,7 +56,7 @@ function SheetContent({
|
|||||||
<SheetPrimitive.Content
|
<SheetPrimitive.Content
|
||||||
data-slot="sheet-content"
|
data-slot="sheet-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=closed]:pointer-events-none",
|
||||||
side === "right" &&
|
side === "right" &&
|
||||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||||
side === "left" &&
|
side === "left" &&
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, VariantProps } from "class-variance-authority"
|
||||||
import { PanelLeftIcon } from "lucide-react"
|
import { PanelLeftIcon } from "lucide-react"
|
||||||
|
|
||||||
import { useIsMobile } from "@/hooks/use-mobile"
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
@@ -137,7 +137,7 @@ function SidebarProvider({
|
|||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex h-full w-full",
|
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -242,7 +242,7 @@ function Sidebar({
|
|||||||
<div
|
<div
|
||||||
data-sidebar="sidebar"
|
data-sidebar="sidebar"
|
||||||
data-slot="sidebar-inner"
|
data-slot="sidebar-inner"
|
||||||
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border-2 group-data-[variant=floating]:shadow-sm"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
@@ -720,6 +720,5 @@ export {
|
|||||||
SidebarRail,
|
SidebarRail,
|
||||||
SidebarSeparator,
|
SidebarSeparator,
|
||||||
SidebarTrigger,
|
SidebarTrigger,
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
|
||||||
useSidebar,
|
useSidebar,
|
||||||
}
|
}
|
||||||
|
|||||||
23
src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import { Toaster as Sonner, ToasterProps } from "sonner"
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
const { theme = "system" } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={theme as ToasterProps["theme"]}
|
||||||
|
className="toaster group"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--normal-bg": "var(--popover)",
|
||||||
|
"--normal-text": "var(--popover-foreground)",
|
||||||
|
"--normal-border": "var(--border)",
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toaster }
|
||||||
114
src/components/ui/table.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="table-container"
|
||||||
|
className="relative w-full overflow-x-auto"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
data-slot="table"
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
|
return (
|
||||||
|
<thead
|
||||||
|
data-slot="table-header"
|
||||||
|
className={cn("[&_tr]:border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
|
return (
|
||||||
|
<tbody
|
||||||
|
data-slot="table-body"
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
|
return (
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
className={cn(
|
||||||
|
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
className={cn(
|
||||||
|
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCaption({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"caption">) {
|
||||||
|
return (
|
||||||
|
<caption
|
||||||
|
data-slot="table-caption"
|
||||||
|
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
@@ -52,7 +52,6 @@ function TooltipContent({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
|
||||||
</TooltipPrimitive.Content>
|
</TooltipPrimitive.Content>
|
||||||
</TooltipPrimitive.Portal>
|
</TooltipPrimitive.Portal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -131,3 +131,47 @@
|
|||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.thin-scrollbar {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #303032 transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thin-scrollbar::-webkit-scrollbar {
|
||||||
|
height: 6px;
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thin-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thin-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #303032;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thin-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thin-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: #18181b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thin-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: #434345;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thin-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #5a5a5d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thin-scrollbar {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #434345 #18181b;
|
||||||
|
}
|
||||||
450
src/ui/Admin/AdminSettings.tsx
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {useSidebar} from "@/components/ui/sidebar";
|
||||||
|
import {Separator} from "@/components/ui/separator.tsx";
|
||||||
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
|
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
|
||||||
|
import {Checkbox} from "@/components/ui/checkbox.tsx";
|
||||||
|
import {Input} from "@/components/ui/input.tsx";
|
||||||
|
import {Label} from "@/components/ui/label.tsx";
|
||||||
|
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table.tsx";
|
||||||
|
import {Shield, Trash2, Users} from "lucide-react";
|
||||||
|
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) => {
|
||||||
|
const parts = v.split('=');
|
||||||
|
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
|
||||||
|
}, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminSettingsProps {
|
||||||
|
isTopbarOpen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.ReactElement {
|
||||||
|
const {state: sidebarState} = useSidebar();
|
||||||
|
|
||||||
|
const [allowRegistration, setAllowRegistration] = React.useState(true);
|
||||||
|
const [regLoading, setRegLoading] = React.useState(false);
|
||||||
|
|
||||||
|
const [oidcConfig, setOidcConfig] = React.useState({
|
||||||
|
client_id: '',
|
||||||
|
client_secret: '',
|
||||||
|
issuer_url: '',
|
||||||
|
authorization_url: '',
|
||||||
|
token_url: '',
|
||||||
|
identifier_path: 'sub',
|
||||||
|
name_path: 'name',
|
||||||
|
scopes: 'openid email profile'
|
||||||
|
});
|
||||||
|
const [oidcLoading, setOidcLoading] = React.useState(false);
|
||||||
|
const [oidcError, setOidcError] = React.useState<string | null>(null);
|
||||||
|
const [oidcSuccess, setOidcSuccess] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const [users, setUsers] = React.useState<Array<{
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
is_admin: boolean;
|
||||||
|
is_oidc: boolean
|
||||||
|
}>>([]);
|
||||||
|
const [usersLoading, setUsersLoading] = React.useState(false);
|
||||||
|
const [newAdminUsername, setNewAdminUsername] = React.useState("");
|
||||||
|
const [makeAdminLoading, setMakeAdminLoading] = React.useState(false);
|
||||||
|
const [makeAdminError, setMakeAdminError] = React.useState<string | null>(null);
|
||||||
|
const [makeAdminSuccess, setMakeAdminSuccess] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const jwt = getCookie("jwt");
|
||||||
|
if (!jwt) return;
|
||||||
|
getOIDCConfig()
|
||||||
|
.then(res => {
|
||||||
|
if (res) setOidcConfig(res);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
});
|
||||||
|
fetchUsers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
getRegistrationAllowed()
|
||||||
|
.then(res => {
|
||||||
|
if (typeof res?.allowed === 'boolean') {
|
||||||
|
setAllowRegistration(res.allowed);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchUsers = async () => {
|
||||||
|
const jwt = getCookie("jwt");
|
||||||
|
if (!jwt) return;
|
||||||
|
setUsersLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await getUserList();
|
||||||
|
setUsers(response.users);
|
||||||
|
} finally {
|
||||||
|
setUsersLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleRegistration = async (checked: boolean) => {
|
||||||
|
setRegLoading(true);
|
||||||
|
const jwt = getCookie("jwt");
|
||||||
|
try {
|
||||||
|
await updateRegistrationAllowed(checked);
|
||||||
|
setAllowRegistration(checked);
|
||||||
|
} finally {
|
||||||
|
setRegLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOIDCConfigSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setOidcLoading(true);
|
||||||
|
setOidcError(null);
|
||||||
|
setOidcSuccess(null);
|
||||||
|
|
||||||
|
const required = ['client_id', 'client_secret', 'issuer_url', 'authorization_url', 'token_url'];
|
||||||
|
const missing = required.filter(f => !oidcConfig[f as keyof typeof oidcConfig]);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
setOidcError(`Missing required fields: ${missing.join(', ')}`);
|
||||||
|
setOidcLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jwt = getCookie("jwt");
|
||||||
|
try {
|
||||||
|
await updateOIDCConfig(oidcConfig);
|
||||||
|
setOidcSuccess("OIDC configuration updated successfully!");
|
||||||
|
} catch (err: any) {
|
||||||
|
setOidcError(err?.response?.data?.error || "Failed to update OIDC configuration");
|
||||||
|
} finally {
|
||||||
|
setOidcLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOIDCConfigChange = (field: string, value: string) => {
|
||||||
|
setOidcConfig(prev => ({...prev, [field]: value}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeUserAdmin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newAdminUsername.trim()) return;
|
||||||
|
setMakeAdminLoading(true);
|
||||||
|
setMakeAdminError(null);
|
||||||
|
setMakeAdminSuccess(null);
|
||||||
|
const jwt = getCookie("jwt");
|
||||||
|
try {
|
||||||
|
await makeUserAdmin(newAdminUsername.trim());
|
||||||
|
setMakeAdminSuccess(`User ${newAdminUsername} is now an admin`);
|
||||||
|
setNewAdminUsername("");
|
||||||
|
fetchUsers();
|
||||||
|
} catch (err: any) {
|
||||||
|
setMakeAdminError(err?.response?.data?.error || "Failed to make user admin");
|
||||||
|
} finally {
|
||||||
|
setMakeAdminLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeAdminStatus = async (username: string) => {
|
||||||
|
if (!confirm(`Remove admin status from ${username}?`)) return;
|
||||||
|
const jwt = getCookie("jwt");
|
||||||
|
try {
|
||||||
|
await removeAdminStatus(username);
|
||||||
|
fetchUsers();
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteUser = async (username: string) => {
|
||||||
|
if (!confirm(`Delete user ${username}? This cannot be undone.`)) return;
|
||||||
|
const jwt = getCookie("jwt");
|
||||||
|
try {
|
||||||
|
await deleteUser(username);
|
||||||
|
fetchUsers();
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||||
|
const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8;
|
||||||
|
const bottomMarginPx = 8;
|
||||||
|
const wrapperStyle: React.CSSProperties = {
|
||||||
|
marginLeft: leftMarginPx,
|
||||||
|
marginRight: 17,
|
||||||
|
marginTop: topMarginPx,
|
||||||
|
marginBottom: bottomMarginPx,
|
||||||
|
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={wrapperStyle}
|
||||||
|
className="bg-[#18181b] text-white rounded-lg border-2 border-[#303032] overflow-hidden">
|
||||||
|
<div className="h-full w-full flex flex-col">
|
||||||
|
<div className="flex items-center justify-between px-3 pt-2 pb-2">
|
||||||
|
<h1 className="font-bold text-lg">Admin Settings</h1>
|
||||||
|
</div>
|
||||||
|
<Separator className="p-0.25 w-full"/>
|
||||||
|
|
||||||
|
<div className="px-6 py-4 overflow-auto">
|
||||||
|
<Tabs defaultValue="registration" className="w-full">
|
||||||
|
<TabsList className="mb-4 bg-[#18181b] border-2 border-[#303032]">
|
||||||
|
<TabsTrigger value="registration" className="flex items-center gap-2">
|
||||||
|
<Users className="h-4 w-4"/>
|
||||||
|
General
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="oidc" className="flex items-center gap-2">
|
||||||
|
<Shield className="h-4 w-4"/>
|
||||||
|
OIDC
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="users" className="flex items-center gap-2">
|
||||||
|
<Users className="h-4 w-4"/>
|
||||||
|
Users
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="admins" className="flex items-center gap-2">
|
||||||
|
<Shield className="h-4 w-4"/>
|
||||||
|
Admins
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="registration" className="space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold">User Registration</h3>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<Checkbox checked={allowRegistration} onCheckedChange={handleToggleRegistration}
|
||||||
|
disabled={regLoading}/>
|
||||||
|
Allow new account registration
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="oidc" className="space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold">External Authentication (OIDC)</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">Configure external identity provider for
|
||||||
|
OIDC/OAuth2 authentication.</p>
|
||||||
|
|
||||||
|
{oidcError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>Error</AlertTitle>
|
||||||
|
<AlertDescription>{oidcError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="client_id">Client ID</Label>
|
||||||
|
<Input id="client_id" value={oidcConfig.client_id}
|
||||||
|
onChange={(e) => handleOIDCConfigChange('client_id', e.target.value)}
|
||||||
|
placeholder="your-client-id" required/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="client_secret">Client Secret</Label>
|
||||||
|
<Input id="client_secret" type="password" value={oidcConfig.client_secret}
|
||||||
|
onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)}
|
||||||
|
placeholder="your-client-secret" required/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="authorization_url">Authorization URL</Label>
|
||||||
|
<Input id="authorization_url" value={oidcConfig.authorization_url}
|
||||||
|
onChange={(e) => handleOIDCConfigChange('authorization_url', e.target.value)}
|
||||||
|
placeholder="https://your-provider.com/application/o/authorize/"
|
||||||
|
required/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="issuer_url">Issuer URL</Label>
|
||||||
|
<Input id="issuer_url" value={oidcConfig.issuer_url}
|
||||||
|
onChange={(e) => handleOIDCConfigChange('issuer_url', e.target.value)}
|
||||||
|
placeholder="https://your-provider.com/application/o/termix/" required/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="token_url">Token URL</Label>
|
||||||
|
<Input id="token_url" value={oidcConfig.token_url}
|
||||||
|
onChange={(e) => handleOIDCConfigChange('token_url', e.target.value)}
|
||||||
|
placeholder="https://your-provider.com/application/o/token/" required/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="identifier_path">User Identifier Path</Label>
|
||||||
|
<Input id="identifier_path" value={oidcConfig.identifier_path}
|
||||||
|
onChange={(e) => handleOIDCConfigChange('identifier_path', e.target.value)}
|
||||||
|
placeholder="sub" required/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name_path">Display Name Path</Label>
|
||||||
|
<Input id="name_path" value={oidcConfig.name_path}
|
||||||
|
onChange={(e) => handleOIDCConfigChange('name_path', e.target.value)}
|
||||||
|
placeholder="name" required/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="scopes">Scopes</Label>
|
||||||
|
<Input id="scopes" value={oidcConfig.scopes}
|
||||||
|
onChange={(e) => handleOIDCConfigChange('scopes', (e.target as HTMLInputElement).value)}
|
||||||
|
placeholder="openid email profile" required/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<Button type="submit" className="flex-1"
|
||||||
|
disabled={oidcLoading}>{oidcLoading ? "Saving..." : "Save Configuration"}</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setOidcConfig({
|
||||||
|
client_id: '',
|
||||||
|
client_secret: '',
|
||||||
|
issuer_url: '',
|
||||||
|
authorization_url: '',
|
||||||
|
token_url: '',
|
||||||
|
identifier_path: 'sub',
|
||||||
|
name_path: 'name',
|
||||||
|
scopes: 'openid email profile'
|
||||||
|
})}>Reset</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{oidcSuccess && (
|
||||||
|
<Alert>
|
||||||
|
<AlertTitle>Success</AlertTitle>
|
||||||
|
<AlertDescription>{oidcSuccess}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="users" className="space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold">User Management</h3>
|
||||||
|
<Button onClick={fetchUsers} disabled={usersLoading} variant="outline"
|
||||||
|
size="sm">{usersLoading ? "Loading..." : "Refresh"}</Button>
|
||||||
|
</div>
|
||||||
|
{usersLoading ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">Loading users...</div>
|
||||||
|
) : (
|
||||||
|
<div className="border rounded-md overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="px-4">Username</TableHead>
|
||||||
|
<TableHead className="px-4">Type</TableHead>
|
||||||
|
<TableHead className="px-4">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{users.map((user) => (
|
||||||
|
<TableRow key={user.id}>
|
||||||
|
<TableCell className="px-4 font-medium">
|
||||||
|
{user.username}
|
||||||
|
{user.is_admin && (
|
||||||
|
<span
|
||||||
|
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">Admin</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
className="px-4">{user.is_oidc ? "External" : "Local"}</TableCell>
|
||||||
|
<TableCell className="px-4">
|
||||||
|
<Button variant="ghost" size="sm"
|
||||||
|
onClick={() => deleteUser(user.username)}
|
||||||
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
|
disabled={user.is_admin}>
|
||||||
|
<Trash2 className="h-4 w-4"/>
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="admins" className="space-y-6">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h3 className="text-lg font-semibold">Admin Management</h3>
|
||||||
|
<div className="space-y-4 p-6 border rounded-md bg-muted/50">
|
||||||
|
<h4 className="font-medium">Make User Admin</h4>
|
||||||
|
<form onSubmit={makeUserAdmin} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="new-admin-username">Username</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input id="new-admin-username" value={newAdminUsername}
|
||||||
|
onChange={(e) => setNewAdminUsername(e.target.value)}
|
||||||
|
placeholder="Enter username to make admin" required/>
|
||||||
|
<Button type="submit"
|
||||||
|
disabled={makeAdminLoading || !newAdminUsername.trim()}>{makeAdminLoading ? "Adding..." : "Make Admin"}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{makeAdminError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>Error</AlertTitle>
|
||||||
|
<AlertDescription>{makeAdminError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{makeAdminSuccess && (
|
||||||
|
<Alert>
|
||||||
|
<AlertTitle>Success</AlertTitle>
|
||||||
|
<AlertDescription>{makeAdminSuccess}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="font-medium">Current Admins</h4>
|
||||||
|
<div className="border rounded-md overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="px-4">Username</TableHead>
|
||||||
|
<TableHead className="px-4">Type</TableHead>
|
||||||
|
<TableHead className="px-4">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{users.filter(u => u.is_admin).map((admin) => (
|
||||||
|
<TableRow key={admin.id}>
|
||||||
|
<TableCell className="px-4 font-medium">
|
||||||
|
{admin.username}
|
||||||
|
<span
|
||||||
|
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">Admin</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
className="px-4">{admin.is_oidc ? "External" : "Local"}</TableCell>
|
||||||
|
<TableCell className="px-4">
|
||||||
|
<Button variant="ghost" size="sm"
|
||||||
|
onClick={() => removeAdminStatus(admin.username)}
|
||||||
|
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50">
|
||||||
|
<Shield className="h-4 w-4"/>
|
||||||
|
Remove Admin
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminSettings;
|
||||||
158
src/ui/Homepage/Homepage.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import React, {useEffect, useState} from "react";
|
||||||
|
import {HomepageAuth} from "@/ui/Homepage/HomepageAuth.tsx";
|
||||||
|
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;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
authLoading: boolean;
|
||||||
|
onAuthSuccess: (authData: { isAdmin: boolean; username: string | null; userId: string | null }) => void;
|
||||||
|
isTopbarOpen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCookie(name: string) {
|
||||||
|
return document.cookie.split('; ').reduce((r, v) => {
|
||||||
|
const parts = v.split('=');
|
||||||
|
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
|
||||||
|
}, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCookie(name: string, value: string, days = 7) {
|
||||||
|
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
||||||
|
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Homepage({
|
||||||
|
onSelectView,
|
||||||
|
isAuthenticated,
|
||||||
|
authLoading,
|
||||||
|
onAuthSuccess,
|
||||||
|
isTopbarOpen = true
|
||||||
|
}: HomepageProps): React.ReactElement {
|
||||||
|
const [loggedIn, setLoggedIn] = useState(isAuthenticated);
|
||||||
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
|
const [username, setUsername] = useState<string | null>(null);
|
||||||
|
const [userId, setUserId] = useState<string | null>(null);
|
||||||
|
const [dbError, setDbError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoggedIn(isAuthenticated);
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
const jwt = getCookie("jwt");
|
||||||
|
if (jwt) {
|
||||||
|
Promise.all([
|
||||||
|
getUserInfo(),
|
||||||
|
getDatabaseHealth()
|
||||||
|
])
|
||||||
|
.then(([meRes]) => {
|
||||||
|
setIsAdmin(!!meRes.is_admin);
|
||||||
|
setUsername(meRes.username || null);
|
||||||
|
setUserId(meRes.userId || null);
|
||||||
|
setDbError(null);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setIsAdmin(false);
|
||||||
|
setUsername(null);
|
||||||
|
setUserId(null);
|
||||||
|
if (err?.response?.data?.error?.includes("Database")) {
|
||||||
|
setDbError("Could not connect to the database. Please try again later.");
|
||||||
|
} else {
|
||||||
|
setDbError(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`w-full min-h-svh relative transition-[padding-top] duration-200 ease-linear ${
|
||||||
|
isTopbarOpen ? 'pt-[66px]' : 'pt-2'
|
||||||
|
}`}>
|
||||||
|
{!loggedIn ? (
|
||||||
|
<div className="absolute top-[66px] left-0 w-full h-[calc(100%-66px)] flex items-center justify-center">
|
||||||
|
<HomepageAuth
|
||||||
|
setLoggedIn={setLoggedIn}
|
||||||
|
setIsAdmin={setIsAdmin}
|
||||||
|
setUsername={setUsername}
|
||||||
|
setUserId={setUserId}
|
||||||
|
loggedIn={loggedIn}
|
||||||
|
authLoading={authLoading}
|
||||||
|
dbError={dbError}
|
||||||
|
setDbError={setDbError}
|
||||||
|
onAuthSuccess={onAuthSuccess}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="absolute top-[66px] left-0 w-full h-[calc(100%-66px)] flex items-center justify-center">
|
||||||
|
<div className="flex flex-row items-center justify-center gap-8 relative z-[10000]">
|
||||||
|
<div className="flex flex-col items-center gap-6 w-[400px]">
|
||||||
|
<div
|
||||||
|
className="text-center bg-[#18181b] border-2 border-[#303032] rounded-lg p-6 w-full shadow-lg">
|
||||||
|
<h3 className="text-xl font-bold mb-3 text-white">Logged in!</h3>
|
||||||
|
<p className="text-gray-300 leading-relaxed">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-row items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-sm border-[#303032] text-gray-300 hover:text-white hover:bg-[#18181b] transition-colors"
|
||||||
|
onClick={() => window.open('https://github.com/LukeGus/Termix', '_blank')}
|
||||||
|
>
|
||||||
|
GitHub
|
||||||
|
</Button>
|
||||||
|
<div className="w-px h-4 bg-[#303032]"></div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-sm border-[#303032] text-gray-300 hover:text-white hover:bg-[#18181b] transition-colors"
|
||||||
|
onClick={() => window.open('https://github.com/LukeGus/Termix/issues/new', '_blank')}
|
||||||
|
>
|
||||||
|
Feedback
|
||||||
|
</Button>
|
||||||
|
<div className="w-px h-4 bg-[#303032]"></div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-sm border-[#303032] text-gray-300 hover:text-white hover:bg-[#18181b] transition-colors"
|
||||||
|
onClick={() => window.open('https://discord.com/invite/jVQGdvHDrf', '_blank')}
|
||||||
|
>
|
||||||
|
Discord
|
||||||
|
</Button>
|
||||||
|
<div className="w-px h-4 bg-[#303032]"></div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-sm border-[#303032] text-gray-300 hover:text-white hover:bg-[#18181b] transition-colors"
|
||||||
|
onClick={() => window.open('https://github.com/sponsors/LukeGus', '_blank')}
|
||||||
|
>
|
||||||
|
Donate
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HomepageUpdateLog
|
||||||
|
loggedIn={loggedIn}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<HomepageAlertManager
|
||||||
|
userId={userId}
|
||||||
|
loggedIn={loggedIn}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
src/ui/Homepage/HomepageAlertCard.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {Card, CardContent, CardFooter, CardHeader, CardTitle} from "@/components/ui/card.tsx";
|
||||||
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
|
import {Badge} from "@/components/ui/badge.tsx";
|
||||||
|
import {X, ExternalLink, AlertTriangle, Info, CheckCircle, AlertCircle} from "lucide-react";
|
||||||
|
|
||||||
|
interface TermixAlert {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
expiresAt: string;
|
||||||
|
priority?: 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
type?: 'info' | 'warning' | 'error' | 'success';
|
||||||
|
actionUrl?: string;
|
||||||
|
actionText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlertCardProps {
|
||||||
|
alert: TermixAlert;
|
||||||
|
onDismiss: (alertId: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAlertIcon = (type?: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'warning':
|
||||||
|
return <AlertTriangle className="h-5 w-5 text-yellow-500"/>;
|
||||||
|
case 'error':
|
||||||
|
return <AlertCircle className="h-5 w-5 text-red-500"/>;
|
||||||
|
case 'success':
|
||||||
|
return <CheckCircle className="h-5 w-5 text-green-500"/>;
|
||||||
|
case 'info':
|
||||||
|
default:
|
||||||
|
return <Info className="h-5 w-5 text-blue-500"/>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPriorityBadgeVariant = (priority?: string) => {
|
||||||
|
switch (priority) {
|
||||||
|
case 'critical':
|
||||||
|
return 'destructive';
|
||||||
|
case 'high':
|
||||||
|
return 'destructive';
|
||||||
|
case 'medium':
|
||||||
|
return 'secondary';
|
||||||
|
case 'low':
|
||||||
|
default:
|
||||||
|
return 'outline';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTypeBadgeVariant = (type?: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'warning':
|
||||||
|
return 'secondary';
|
||||||
|
case 'error':
|
||||||
|
return 'destructive';
|
||||||
|
case 'success':
|
||||||
|
return 'default';
|
||||||
|
case 'info':
|
||||||
|
default:
|
||||||
|
return 'outline';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function HomepageAlertCard({alert, onDismiss, onClose}: AlertCardProps): React.ReactElement {
|
||||||
|
if (!alert) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDismiss = () => {
|
||||||
|
onDismiss(alert.id);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatExpiryDate = (expiryString: string) => {
|
||||||
|
const expiryDate = new Date(expiryString);
|
||||||
|
const now = new Date();
|
||||||
|
const diffTime = expiryDate.getTime() - now.getTime();
|
||||||
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (diffDays < 0) return 'Expired';
|
||||||
|
if (diffDays === 0) return 'Expires today';
|
||||||
|
if (diffDays === 1) return 'Expires tomorrow';
|
||||||
|
return `Expires in ${diffDays} days`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-2xl mx-auto">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{getAlertIcon(alert.type)}
|
||||||
|
<CardTitle className="text-xl font-bold">
|
||||||
|
{alert.title}
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onClose}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4"/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
{alert.priority && (
|
||||||
|
<Badge variant={getPriorityBadgeVariant(alert.priority)}>
|
||||||
|
{alert.priority.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{alert.type && (
|
||||||
|
<Badge variant={getTypeBadgeVariant(alert.type)}>
|
||||||
|
{alert.type}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{formatExpiryDate(alert.expiresAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pb-4">
|
||||||
|
<p className="text-muted-foreground leading-relaxed whitespace-pre-wrap">
|
||||||
|
{alert.message}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex items-center justify-between pt-0">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleDismiss}
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
{alert.actionUrl && alert.actionText && (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={() => window.open(alert.actionUrl, '_blank', 'noopener,noreferrer')}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{alert.actionText}
|
||||||
|
<ExternalLink className="h-4 w-4"/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
177
src/ui/Homepage/HomepageAlertManager.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import React, {useEffect, useState} from "react";
|
||||||
|
import {HomepageAlertCard} from "./HomepageAlertCard.tsx";
|
||||||
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
|
import { getUserAlerts, dismissAlert } from "@/ui/main-axios.ts";
|
||||||
|
|
||||||
|
interface TermixAlert {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
expiresAt: string;
|
||||||
|
priority?: 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
type?: 'info' | 'warning' | 'error' | 'success';
|
||||||
|
actionUrl?: string;
|
||||||
|
actionText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlertManagerProps {
|
||||||
|
userId: string | null;
|
||||||
|
loggedIn: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): React.ReactElement {
|
||||||
|
const [alerts, setAlerts] = useState<TermixAlert[]>([]);
|
||||||
|
const [currentAlertIndex, setCurrentAlertIndex] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loggedIn && userId) {
|
||||||
|
fetchUserAlerts();
|
||||||
|
}
|
||||||
|
}, [loggedIn, userId]);
|
||||||
|
|
||||||
|
const fetchUserAlerts = async () => {
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await getUserAlerts(userId);
|
||||||
|
|
||||||
|
const userAlerts = response.alerts || [];
|
||||||
|
|
||||||
|
const sortedAlerts = userAlerts.sort((a: TermixAlert, b: TermixAlert) => {
|
||||||
|
const priorityOrder = {critical: 4, high: 3, medium: 2, low: 1};
|
||||||
|
const aPriority = priorityOrder[a.priority as keyof typeof priorityOrder] || 0;
|
||||||
|
const bPriority = priorityOrder[b.priority as keyof typeof priorityOrder] || 0;
|
||||||
|
|
||||||
|
if (aPriority !== bPriority) {
|
||||||
|
return bPriority - aPriority;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(a.expiresAt).getTime() - new Date(b.expiresAt).getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
setAlerts(sortedAlerts);
|
||||||
|
setCurrentAlertIndex(0);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to load alerts');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDismissAlert = async (alertId: string) => {
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await dismissAlert(userId, alertId);
|
||||||
|
|
||||||
|
setAlerts(prev => {
|
||||||
|
const newAlerts = prev.filter(alert => alert.id !== alertId);
|
||||||
|
return newAlerts;
|
||||||
|
});
|
||||||
|
|
||||||
|
setCurrentAlertIndex(prevIndex => {
|
||||||
|
const newAlertsLength = alerts.length - 1;
|
||||||
|
if (newAlertsLength === 0) return 0;
|
||||||
|
if (prevIndex >= newAlertsLength) return Math.max(0, newAlertsLength - 1);
|
||||||
|
return prevIndex;
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to dismiss alert');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseCurrentAlert = () => {
|
||||||
|
if (alerts.length === 0) return;
|
||||||
|
|
||||||
|
if (currentAlertIndex < alerts.length - 1) {
|
||||||
|
setCurrentAlertIndex(currentAlertIndex + 1);
|
||||||
|
} else {
|
||||||
|
setAlerts([]);
|
||||||
|
setCurrentAlertIndex(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePreviousAlert = () => {
|
||||||
|
if (currentAlertIndex > 0) {
|
||||||
|
setCurrentAlertIndex(currentAlertIndex - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNextAlert = () => {
|
||||||
|
if (currentAlertIndex < alerts.length - 1) {
|
||||||
|
setCurrentAlertIndex(currentAlertIndex + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!loggedIn || !userId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alerts.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentAlert = alerts[currentAlertIndex];
|
||||||
|
|
||||||
|
if (!currentAlert) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorityCounts = {critical: 0, high: 0, medium: 0, low: 0};
|
||||||
|
alerts.forEach(alert => {
|
||||||
|
const priority = alert.priority || 'low';
|
||||||
|
priorityCounts[priority as keyof typeof priorityCounts]++;
|
||||||
|
});
|
||||||
|
const hasMultipleAlerts = alerts.length > 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-[99999]">
|
||||||
|
<div className="relative w-full max-w-2xl mx-4">
|
||||||
|
<HomepageAlertCard
|
||||||
|
alert={currentAlert}
|
||||||
|
onDismiss={handleDismissAlert}
|
||||||
|
onClose={handleCloseCurrentAlert}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{hasMultipleAlerts && (
|
||||||
|
<div className="absolute -bottom-16 left-1/2 transform -translate-x-1/2 flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handlePreviousAlert}
|
||||||
|
disabled={currentAlertIndex === 0}
|
||||||
|
className="h-8 px-3"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{currentAlertIndex + 1} of {alerts.length}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleNextAlert}
|
||||||
|
disabled={currentAlertIndex === alerts.length - 1}
|
||||||
|
className="h-8 px-3"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="absolute -bottom-20 left-1/2 transform -translate-x-1/2">
|
||||||
|
<div className="bg-destructive text-destructive-foreground px-3 py-1 rounded text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
689
src/ui/Homepage/HomepageAuth.tsx
Normal file
@@ -0,0 +1,689 @@
|
|||||||
|
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 {
|
||||||
|
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();
|
||||||
|
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCookie(name: string) {
|
||||||
|
return document.cookie.split('; ').reduce((r, v) => {
|
||||||
|
const parts = v.split('=');
|
||||||
|
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
|
||||||
|
}, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
interface HomepageAuthProps extends React.ComponentProps<"div"> {
|
||||||
|
setLoggedIn: (loggedIn: boolean) => void;
|
||||||
|
setIsAdmin: (isAdmin: boolean) => void;
|
||||||
|
setUsername: (username: string | null) => void;
|
||||||
|
setUserId: (userId: string | null) => void;
|
||||||
|
loggedIn: boolean;
|
||||||
|
authLoading: boolean;
|
||||||
|
dbError: string | null;
|
||||||
|
setDbError: (error: string | null) => void;
|
||||||
|
onAuthSuccess: (authData: { isAdmin: boolean; username: string | null; userId: string | null }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HomepageAuth({
|
||||||
|
className,
|
||||||
|
setLoggedIn,
|
||||||
|
setIsAdmin,
|
||||||
|
setUsername,
|
||||||
|
setUserId,
|
||||||
|
loggedIn,
|
||||||
|
authLoading,
|
||||||
|
dbError,
|
||||||
|
setDbError,
|
||||||
|
onAuthSuccess,
|
||||||
|
...props
|
||||||
|
}: HomepageAuthProps) {
|
||||||
|
const [tab, setTab] = useState<"login" | "signup" | "external" | "reset">("login");
|
||||||
|
const [localUsername, setLocalUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [signupConfirmPassword, setSignupConfirmPassword] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [oidcLoading, setOidcLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [internalLoggedIn, setInternalLoggedIn] = useState(false);
|
||||||
|
const [firstUser, setFirstUser] = useState(false);
|
||||||
|
const [registrationAllowed, setRegistrationAllowed] = useState(true);
|
||||||
|
const [oidcConfigured, setOidcConfigured] = useState(false);
|
||||||
|
|
||||||
|
const [resetStep, setResetStep] = useState<"initiate" | "verify" | "newPassword">("initiate");
|
||||||
|
const [resetCode, setResetCode] = useState("");
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [tempToken, setTempToken] = useState("");
|
||||||
|
const [resetLoading, setResetLoading] = useState(false);
|
||||||
|
const [resetSuccess, setResetSuccess] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setInternalLoggedIn(loggedIn);
|
||||||
|
}, [loggedIn]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getRegistrationAllowed().then(res => {
|
||||||
|
setRegistrationAllowed(res.allowed);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getOIDCConfig().then((response) => {
|
||||||
|
if (response) {
|
||||||
|
setOidcConfigured(true);
|
||||||
|
} else {
|
||||||
|
setOidcConfigured(false);
|
||||||
|
}
|
||||||
|
}).catch((error) => {
|
||||||
|
if (error.response?.status === 404) {
|
||||||
|
setOidcConfigured(false);
|
||||||
|
} else {
|
||||||
|
setOidcConfigured(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getUserCount().then(res => {
|
||||||
|
if (res.count === 0) {
|
||||||
|
setFirstUser(true);
|
||||||
|
setTab("signup");
|
||||||
|
} else {
|
||||||
|
setFirstUser(false);
|
||||||
|
}
|
||||||
|
setDbError(null);
|
||||||
|
}).catch(() => {
|
||||||
|
setDbError("Could not connect to the database. Please try again later.");
|
||||||
|
});
|
||||||
|
}, [setDbError]);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
if (!localUsername.trim()) {
|
||||||
|
setError("Username is required");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let res, meRes;
|
||||||
|
if (tab === "login") {
|
||||||
|
res = await loginUser(localUsername, password);
|
||||||
|
} else {
|
||||||
|
if (password !== signupConfirmPassword) {
|
||||||
|
setError("Passwords do not match");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password.length < 6) {
|
||||||
|
setError("Password must be at least 6 characters long");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await registerUser(localUsername, password);
|
||||||
|
res = await loginUser(localUsername, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res || !res.token) {
|
||||||
|
throw new Error('No token received from login');
|
||||||
|
}
|
||||||
|
|
||||||
|
setCookie("jwt", res.token);
|
||||||
|
[meRes] = await Promise.all([
|
||||||
|
getUserInfo(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setInternalLoggedIn(true);
|
||||||
|
setLoggedIn(true);
|
||||||
|
setIsAdmin(!!meRes.is_admin);
|
||||||
|
setUsername(meRes.username || null);
|
||||||
|
setUserId(meRes.userId || null);
|
||||||
|
setDbError(null);
|
||||||
|
onAuthSuccess({
|
||||||
|
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 || err?.message || "Unknown error");
|
||||||
|
setInternalLoggedIn(false);
|
||||||
|
setLoggedIn(false);
|
||||||
|
setIsAdmin(false);
|
||||||
|
setUsername(null);
|
||||||
|
setUserId(null);
|
||||||
|
setCookie("jwt", "", -1);
|
||||||
|
if (err?.response?.data?.error?.includes("Database")) {
|
||||||
|
setDbError("Could not connect to the database. Please try again later.");
|
||||||
|
} else {
|
||||||
|
setDbError(null);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleInitiatePasswordReset() {
|
||||||
|
setError(null);
|
||||||
|
setResetLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await initiatePasswordReset(localUsername);
|
||||||
|
setResetStep("verify");
|
||||||
|
setError(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.response?.data?.error || err?.message || "Failed to initiate password reset");
|
||||||
|
} finally {
|
||||||
|
setResetLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleVerifyResetCode() {
|
||||||
|
setError(null);
|
||||||
|
setResetLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await verifyPasswordResetCode(localUsername, resetCode);
|
||||||
|
setTempToken(response.tempToken);
|
||||||
|
setResetStep("newPassword");
|
||||||
|
setError(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.response?.data?.error || "Failed to verify reset code");
|
||||||
|
} finally {
|
||||||
|
setResetLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCompletePasswordReset() {
|
||||||
|
setError(null);
|
||||||
|
setResetLoading(true);
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setError("Passwords do not match");
|
||||||
|
setResetLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
setError("Password must be at least 6 characters long");
|
||||||
|
setResetLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await completePasswordReset(localUsername, tempToken, newPassword);
|
||||||
|
|
||||||
|
setResetStep("initiate");
|
||||||
|
setResetCode("");
|
||||||
|
setNewPassword("");
|
||||||
|
setConfirmPassword("");
|
||||||
|
setTempToken("");
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
setResetSuccess(true);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.response?.data?.error || "Failed to complete password reset");
|
||||||
|
} finally {
|
||||||
|
setResetLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPasswordState() {
|
||||||
|
setResetStep("initiate");
|
||||||
|
setResetCode("");
|
||||||
|
setNewPassword("");
|
||||||
|
setConfirmPassword("");
|
||||||
|
setTempToken("");
|
||||||
|
setError(null);
|
||||||
|
setResetSuccess(false);
|
||||||
|
setSignupConfirmPassword("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFormFields() {
|
||||||
|
setPassword("");
|
||||||
|
setSignupConfirmPassword("");
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOIDCLogin() {
|
||||||
|
setError(null);
|
||||||
|
setOidcLoading(true);
|
||||||
|
try {
|
||||||
|
const authResponse = await getOIDCAuthorizeUrl();
|
||||||
|
const {auth_url: authUrl} = authResponse;
|
||||||
|
|
||||||
|
if (!authUrl || authUrl === 'undefined') {
|
||||||
|
throw new Error('Invalid authorization URL received from backend');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.replace(authUrl);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.response?.data?.error || err?.message || "Failed to start OIDC login");
|
||||||
|
setOidcLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const success = urlParams.get('success');
|
||||||
|
const token = urlParams.get('token');
|
||||||
|
const error = urlParams.get('error');
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setError(`OIDC authentication failed: ${error}`);
|
||||||
|
setOidcLoading(false);
|
||||||
|
window.history.replaceState({}, document.title, window.location.pathname);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success && token) {
|
||||||
|
setOidcLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
setCookie("jwt", token);
|
||||||
|
getUserInfo()
|
||||||
|
.then(meRes => {
|
||||||
|
setInternalLoggedIn(true);
|
||||||
|
setLoggedIn(true);
|
||||||
|
setIsAdmin(!!meRes.is_admin);
|
||||||
|
setUsername(meRes.username || null);
|
||||||
|
setUserId(meRes.id || null);
|
||||||
|
setDbError(null);
|
||||||
|
onAuthSuccess({
|
||||||
|
isAdmin: !!meRes.is_admin,
|
||||||
|
username: meRes.username || null,
|
||||||
|
userId: meRes.id || null
|
||||||
|
});
|
||||||
|
setInternalLoggedIn(true);
|
||||||
|
window.history.replaceState({}, document.title, window.location.pathname);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
setError("Failed to get user info after OIDC login");
|
||||||
|
setInternalLoggedIn(false);
|
||||||
|
setLoggedIn(false);
|
||||||
|
setIsAdmin(false);
|
||||||
|
setUsername(null);
|
||||||
|
setUserId(null);
|
||||||
|
setCookie("jwt", "", -1);
|
||||||
|
window.history.replaceState({}, document.title, window.location.pathname);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setOidcLoading(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const Spinner = (
|
||||||
|
<svg className="animate-spin mr-2 h-4 w-4 text-white inline-block" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`w-[420px] max-w-full p-6 flex flex-col bg-[#18181b] border-2 border-[#303032] rounded-md ${className || ''}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{dbError && (
|
||||||
|
<Alert variant="destructive" className="mb-4">
|
||||||
|
<AlertTitle>Error</AlertTitle>
|
||||||
|
<AlertDescription>{dbError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{firstUser && !dbError && !internalLoggedIn && (
|
||||||
|
<Alert variant="default" className="mb-4">
|
||||||
|
<AlertTitle>First User</AlertTitle>
|
||||||
|
<AlertDescription className="inline">
|
||||||
|
You are the first user and will be made an admin. You can view admin settings in the sidebar
|
||||||
|
user dropdown. If you think this is a mistake, check the docker logs, or create a{" "}
|
||||||
|
<a
|
||||||
|
href="https://github.com/LukeGus/Termix/issues/new"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600 underline hover:text-blue-800 inline"
|
||||||
|
>
|
||||||
|
GitHub issue
|
||||||
|
</a>.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{!registrationAllowed && !internalLoggedIn && (
|
||||||
|
<Alert variant="destructive" className="mb-4">
|
||||||
|
<AlertTitle>Registration Disabled</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
New account registration is currently disabled by an admin. Please log in or contact an
|
||||||
|
administrator.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{(!internalLoggedIn && (!authLoading || !getCookie("jwt"))) && (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-2 mb-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||||
|
tab === "login"
|
||||||
|
? "bg-primary text-primary-foreground shadow"
|
||||||
|
: "bg-muted text-muted-foreground hover:bg-accent"
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setTab("login");
|
||||||
|
if (tab === "reset") resetPasswordState();
|
||||||
|
if (tab === "signup") clearFormFields();
|
||||||
|
}}
|
||||||
|
aria-selected={tab === "login"}
|
||||||
|
disabled={loading || firstUser}
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||||
|
tab === "signup"
|
||||||
|
? "bg-primary text-primary-foreground shadow"
|
||||||
|
: "bg-muted text-muted-foreground hover:bg-accent"
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setTab("signup");
|
||||||
|
if (tab === "reset") resetPasswordState();
|
||||||
|
if (tab === "login") clearFormFields();
|
||||||
|
}}
|
||||||
|
aria-selected={tab === "signup"}
|
||||||
|
disabled={loading || !registrationAllowed}
|
||||||
|
>
|
||||||
|
Sign Up
|
||||||
|
</button>
|
||||||
|
{oidcConfigured && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||||
|
tab === "external"
|
||||||
|
? "bg-primary text-primary-foreground shadow"
|
||||||
|
: "bg-muted text-muted-foreground hover:bg-accent"
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setTab("external");
|
||||||
|
if (tab === "reset") resetPasswordState();
|
||||||
|
if (tab === "login" || tab === "signup") clearFormFields();
|
||||||
|
}}
|
||||||
|
aria-selected={tab === "external"}
|
||||||
|
disabled={oidcLoading}
|
||||||
|
>
|
||||||
|
External
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mb-6 text-center">
|
||||||
|
<h2 className="text-xl font-bold mb-1">
|
||||||
|
{tab === "login" ? "Login to your account" :
|
||||||
|
tab === "signup" ? "Create a new account" :
|
||||||
|
tab === "external" ? "Login with external provider" :
|
||||||
|
"Reset your password"}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === "external" || tab === "reset" ? (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
{tab === "external" && (
|
||||||
|
<>
|
||||||
|
<div className="text-center text-muted-foreground mb-4">
|
||||||
|
<p>Login using your configured external identity provider</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full h-11 mt-2 text-base font-semibold"
|
||||||
|
disabled={oidcLoading}
|
||||||
|
onClick={handleOIDCLogin}
|
||||||
|
>
|
||||||
|
{oidcLoading ? Spinner : "Login with External Provider"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{tab === "reset" && (
|
||||||
|
<>
|
||||||
|
{resetStep === "initiate" && (
|
||||||
|
<>
|
||||||
|
<div className="text-center text-muted-foreground mb-4">
|
||||||
|
<p>Enter your username to receive a password reset code. The code
|
||||||
|
will be logged in the docker container logs.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="reset-username">Username</Label>
|
||||||
|
<Input
|
||||||
|
id="reset-username"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
className="h-11 text-base"
|
||||||
|
value={localUsername}
|
||||||
|
onChange={e => setLocalUsername(e.target.value)}
|
||||||
|
disabled={resetLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full h-11 text-base font-semibold"
|
||||||
|
disabled={resetLoading || !localUsername.trim()}
|
||||||
|
onClick={handleInitiatePasswordReset}
|
||||||
|
>
|
||||||
|
{resetLoading ? Spinner : "Send Reset Code"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{resetStep === "verify" && (
|
||||||
|
<>
|
||||||
|
<div className="text-center text-muted-foreground mb-4">
|
||||||
|
<p>Enter the 6-digit code from the docker container logs for
|
||||||
|
user: <strong>{localUsername}</strong></p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="reset-code">Reset Code</Label>
|
||||||
|
<Input
|
||||||
|
id="reset-code"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
maxLength={6}
|
||||||
|
className="h-11 text-base text-center text-lg tracking-widest"
|
||||||
|
value={resetCode}
|
||||||
|
onChange={e => setResetCode(e.target.value.replace(/\D/g, ''))}
|
||||||
|
disabled={resetLoading}
|
||||||
|
placeholder="000000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full h-11 text-base font-semibold"
|
||||||
|
disabled={resetLoading || resetCode.length !== 6}
|
||||||
|
onClick={handleVerifyResetCode}
|
||||||
|
>
|
||||||
|
{resetLoading ? Spinner : "Verify Code"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full h-11 text-base font-semibold"
|
||||||
|
disabled={resetLoading}
|
||||||
|
onClick={() => {
|
||||||
|
setResetStep("initiate");
|
||||||
|
setResetCode("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{resetSuccess && (
|
||||||
|
<>
|
||||||
|
<Alert className="mb-4">
|
||||||
|
<AlertTitle>Success!</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Your password has been successfully reset! You can now log in
|
||||||
|
with your new password.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full h-11 text-base font-semibold"
|
||||||
|
onClick={() => {
|
||||||
|
setTab("login");
|
||||||
|
resetPasswordState();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Go to Login
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{resetStep === "newPassword" && !resetSuccess && (
|
||||||
|
<>
|
||||||
|
<div className="text-center text-muted-foreground mb-4">
|
||||||
|
<p>Enter your new password for
|
||||||
|
user: <strong>{localUsername}</strong></p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="new-password">New Password</Label>
|
||||||
|
<Input
|
||||||
|
id="new-password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={e => setNewPassword(e.target.value)}
|
||||||
|
disabled={resetLoading}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="confirm-password">Confirm Password</Label>
|
||||||
|
<Input
|
||||||
|
id="confirm-password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={e => setConfirmPassword(e.target.value)}
|
||||||
|
disabled={resetLoading}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full h-11 text-base font-semibold"
|
||||||
|
disabled={resetLoading || !newPassword || !confirmPassword}
|
||||||
|
onClick={handleCompletePasswordReset}
|
||||||
|
>
|
||||||
|
{resetLoading ? Spinner : "Reset Password"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full h-11 text-base font-semibold"
|
||||||
|
disabled={resetLoading}
|
||||||
|
onClick={() => {
|
||||||
|
setResetStep("verify");
|
||||||
|
setNewPassword("");
|
||||||
|
setConfirmPassword("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="username">Username</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
className="h-11 text-base"
|
||||||
|
value={localUsername}
|
||||||
|
onChange={e => setLocalUsername(e.target.value)}
|
||||||
|
disabled={loading || internalLoggedIn}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input id="password" type="password" required className="h-11 text-base"
|
||||||
|
value={password} onChange={e => setPassword(e.target.value)}
|
||||||
|
disabled={loading || internalLoggedIn}/>
|
||||||
|
</div>
|
||||||
|
{tab === "signup" && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="signup-confirm-password">Confirm Password</Label>
|
||||||
|
<Input id="signup-confirm-password" type="password" required
|
||||||
|
className="h-11 text-base"
|
||||||
|
value={signupConfirmPassword}
|
||||||
|
onChange={e => setSignupConfirmPassword(e.target.value)}
|
||||||
|
disabled={loading || internalLoggedIn}/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button type="submit" className="w-full h-11 mt-2 text-base font-semibold"
|
||||||
|
disabled={loading || internalLoggedIn}>
|
||||||
|
{loading ? Spinner : (tab === "login" ? "Login" : "Sign Up")}
|
||||||
|
</Button>
|
||||||
|
{tab === "login" && (
|
||||||
|
<Button type="button" variant="outline"
|
||||||
|
className="w-full h-11 text-base font-semibold"
|
||||||
|
disabled={loading || internalLoggedIn}
|
||||||
|
onClick={() => {
|
||||||
|
setTab("reset");
|
||||||
|
resetPasswordState();
|
||||||
|
clearFormFields();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset Password
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive" className="mt-4">
|
||||||
|
<AlertTitle>Error</AlertTitle>
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import React, {useEffect, useState} from "react";
|
|||||||
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
|
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
|
||||||
import {Button} from "@/components/ui/button.tsx";
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
import {Separator} from "@/components/ui/separator.tsx";
|
import {Separator} from "@/components/ui/separator.tsx";
|
||||||
import axios from "axios";
|
import { getReleasesRSS, getVersionInfo } from "@/ui/main-axios.ts";
|
||||||
|
|
||||||
interface HomepageUpdateLogProps extends React.ComponentProps<"div"> {
|
interface HomepageUpdateLogProps extends React.ComponentProps<"div"> {
|
||||||
loggedIn: boolean;
|
loggedIn: boolean;
|
||||||
@@ -50,12 +50,6 @@ interface VersionResponse {
|
|||||||
cache_age?: number;
|
cache_age?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiBase = import.meta.env.DEV ? "http://localhost:8081" : "";
|
|
||||||
|
|
||||||
const API = axios.create({
|
|
||||||
baseURL: apiBase,
|
|
||||||
});
|
|
||||||
|
|
||||||
export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
|
export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
|
||||||
const [releases, setReleases] = useState<RSSResponse | null>(null);
|
const [releases, setReleases] = useState<RSSResponse | null>(null);
|
||||||
const [versionInfo, setVersionInfo] = useState<VersionResponse | null>(null);
|
const [versionInfo, setVersionInfo] = useState<VersionResponse | null>(null);
|
||||||
@@ -66,12 +60,12 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
|
|||||||
if (loggedIn) {
|
if (loggedIn) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
Promise.all([
|
Promise.all([
|
||||||
API.get('/releases/rss?per_page=100'),
|
getReleasesRSS(100),
|
||||||
API.get('/version/')
|
getVersionInfo()
|
||||||
])
|
])
|
||||||
.then(([releasesRes, versionRes]) => {
|
.then(([releasesRes, versionRes]) => {
|
||||||
setReleases(releasesRes.data);
|
setReleases(releasesRes);
|
||||||
setVersionInfo(versionRes.data);
|
setVersionInfo(versionRes);
|
||||||
setError(null);
|
setError(null);
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
@@ -94,70 +88,63 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-[400px] h-[600px] flex flex-col border border-border rounded-lg bg-card p-4">
|
<div className="w-[400px] h-[600px] flex flex-col border-2 border-[#303032] rounded-lg bg-[#18181b] p-4 shadow-lg">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold mb-3">Updates & Releases</h3>
|
<h3 className="text-lg font-bold mb-3 text-white">Updates & Releases</h3>
|
||||||
|
|
||||||
<Separator className="p-0.25 mt-3 mb-3"/>
|
<Separator className="p-0.25 mt-3 mb-3 bg-[#303032]"/>
|
||||||
|
|
||||||
{versionInfo && versionInfo.status === 'requires_update' && (
|
{versionInfo && versionInfo.status === 'requires_update' && (
|
||||||
<Alert>
|
<Alert className="bg-[#0e0e10] border-[#303032] text-white">
|
||||||
<AlertTitle>Update Available</AlertTitle>
|
<AlertTitle className="text-white">Update Available</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription className="text-gray-300">
|
||||||
A new version ({versionInfo.version}) is available.
|
A new version ({versionInfo.version}) is available.
|
||||||
<Button
|
|
||||||
variant="link"
|
|
||||||
className="p-0 h-auto underline ml-1"
|
|
||||||
onClick={() => window.open("https://docs.termix.site/docs", '_blank')}
|
|
||||||
>
|
|
||||||
Update now
|
|
||||||
</Button>
|
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{versionInfo && versionInfo.status === 'requires_update' && (
|
{versionInfo && versionInfo.status === 'requires_update' && (
|
||||||
<Separator className="p-0.25 mt-3 mb-3"/>
|
<Separator className="p-0.25 mt-3 mb-3 bg-[#303032]"/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto space-y-3">
|
<div className="flex-1 overflow-y-auto space-y-3 pr-2">
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex items-center justify-center h-32">
|
<div className="flex items-center justify-center h-32">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive" className="bg-red-900/20 border-red-500 text-red-300">
|
||||||
<AlertTitle>Error</AlertTitle>
|
<AlertTitle className="text-red-300">Error</AlertTitle>
|
||||||
<AlertDescription>{error}</AlertDescription>
|
<AlertDescription className="text-red-300">{error}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{releases?.items.map((release) => (
|
{releases?.items.map((release) => (
|
||||||
<div
|
<div
|
||||||
key={release.id}
|
key={release.id}
|
||||||
className="border border-border rounded-lg p-3 hover:bg-accent transition-colors cursor-pointer"
|
className="border border-[#303032] rounded-lg p-3 hover:bg-[#0e0e10] transition-colors cursor-pointer bg-[#0e0e10]/50"
|
||||||
onClick={() => window.open(release.link, '_blank')}
|
onClick={() => window.open(release.link, '_blank')}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between mb-2">
|
<div className="flex items-start justify-between mb-2">
|
||||||
<h4 className="font-medium text-sm leading-tight flex-1">
|
<h4 className="font-semibold text-sm leading-tight flex-1 text-white">
|
||||||
{release.title}
|
{release.title}
|
||||||
</h4>
|
</h4>
|
||||||
{release.isPrerelease && (
|
{release.isPrerelease && (
|
||||||
<span
|
<span
|
||||||
className="text-xs bg-yellow-100 text-yellow-800 px-2 py-1 rounded ml-2 flex-shrink-0">
|
className="text-xs bg-yellow-600 text-yellow-100 px-2 py-1 rounded ml-2 flex-shrink-0 font-medium">
|
||||||
Pre-release
|
Pre-release
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground mb-2 leading-relaxed">
|
<p className="text-xs text-gray-300 mb-2 leading-relaxed">
|
||||||
{formatDescription(release.description)}
|
{formatDescription(release.description)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center text-xs text-muted-foreground">
|
<div className="flex items-center text-xs text-gray-400">
|
||||||
<span>{new Date(release.pubDate).toLocaleDateString()}</span>
|
<span>{new Date(release.pubDate).toLocaleDateString()}</span>
|
||||||
{release.assets.length > 0 && (
|
{release.assets.length > 0 && (
|
||||||
<>
|
<>
|
||||||
@@ -170,9 +157,9 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{releases && releases.items.length === 0 && !loading && (
|
{releases && releases.items.length === 0 && !loading && (
|
||||||
<Alert>
|
<Alert className="bg-[#0e0e10] border-[#303032] text-gray-300">
|
||||||
<AlertTitle>No Releases</AlertTitle>
|
<AlertTitle className="text-gray-300">No Releases</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription className="text-gray-400">
|
||||||
No releases found.
|
No releases found.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
553
src/ui/Navigation/AppView.tsx
Normal file
@@ -0,0 +1,553 @@
|
|||||||
|
import React, {useEffect, useRef, useState} from "react";
|
||||||
|
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";
|
||||||
|
import {ResizablePanelGroup, ResizablePanel, ResizableHandle} from '@/components/ui/resizable.tsx';
|
||||||
|
import * as ResizablePrimitive from "react-resizable-panels";
|
||||||
|
import {useSidebar} from "@/components/ui/sidebar.tsx";
|
||||||
|
import {LucideRefreshCcw, LucideRefreshCw, RefreshCcw, RefreshCcwDot} from "lucide-react";
|
||||||
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
|
|
||||||
|
interface TerminalViewProps {
|
||||||
|
isTopbarOpen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactElement {
|
||||||
|
const {tabs, currentTab, allSplitScreenTab} = useTabs() as any;
|
||||||
|
const {state: sidebarState} = useSidebar();
|
||||||
|
|
||||||
|
const terminalTabs = tabs.filter((tab: any) => tab.type === 'terminal' || tab.type === 'server' || tab.type === 'file_manager');
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const panelRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||||
|
const [panelRects, setPanelRects] = useState<Record<string, DOMRect | null>>({});
|
||||||
|
const [ready, setReady] = useState<boolean>(true);
|
||||||
|
const [resetKey, setResetKey] = useState<number>(0);
|
||||||
|
|
||||||
|
const updatePanelRects = () => {
|
||||||
|
const next: Record<string, DOMRect | null> = {};
|
||||||
|
Object.entries(panelRefs.current).forEach(([id, el]) => {
|
||||||
|
if (el) next[id] = el.getBoundingClientRect();
|
||||||
|
});
|
||||||
|
setPanelRects(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fitActiveAndNotify = () => {
|
||||||
|
const visibleIds: number[] = [];
|
||||||
|
if (allSplitScreenTab.length === 0) {
|
||||||
|
if (currentTab) visibleIds.push(currentTab);
|
||||||
|
} else {
|
||||||
|
const splitIds = allSplitScreenTab as number[];
|
||||||
|
visibleIds.push(currentTab, ...splitIds.filter((i) => i !== currentTab));
|
||||||
|
}
|
||||||
|
terminalTabs.forEach((t: any) => {
|
||||||
|
if (visibleIds.includes(t.id)) {
|
||||||
|
const ref = t.terminalRef?.current;
|
||||||
|
if (ref?.fit) ref.fit();
|
||||||
|
if (ref?.notifyResize) ref.notifyResize();
|
||||||
|
if (ref?.refresh) ref.refresh();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const layoutScheduleRef = useRef<number | null>(null);
|
||||||
|
const scheduleMeasureAndFit = () => {
|
||||||
|
if (layoutScheduleRef.current) cancelAnimationFrame(layoutScheduleRef.current);
|
||||||
|
layoutScheduleRef.current = requestAnimationFrame(() => {
|
||||||
|
updatePanelRects();
|
||||||
|
layoutScheduleRef.current = requestAnimationFrame(() => {
|
||||||
|
fitActiveAndNotify();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideThenFit = () => {
|
||||||
|
setReady(false);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
updatePanelRects();
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
fitActiveAndNotify();
|
||||||
|
setReady(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
hideThenFit();
|
||||||
|
}, [currentTab, terminalTabs.length, allSplitScreenTab.join(',')]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scheduleMeasureAndFit();
|
||||||
|
}, [allSplitScreenTab.length, isTopbarOpen, sidebarState, resetKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const roContainer = containerRef.current ? new ResizeObserver(() => {
|
||||||
|
updatePanelRects();
|
||||||
|
fitActiveAndNotify();
|
||||||
|
}) : null;
|
||||||
|
if (containerRef.current && roContainer) roContainer.observe(containerRef.current);
|
||||||
|
return () => roContainer?.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onWinResize = () => {
|
||||||
|
updatePanelRects();
|
||||||
|
fitActiveAndNotify();
|
||||||
|
};
|
||||||
|
window.addEventListener('resize', onWinResize);
|
||||||
|
return () => window.removeEventListener('resize', onWinResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const HEADER_H = 28;
|
||||||
|
|
||||||
|
const renderTerminalsLayer = () => {
|
||||||
|
const styles: Record<number, React.CSSProperties> = {};
|
||||||
|
const splitTabs = terminalTabs.filter((tab: any) => allSplitScreenTab.includes(tab.id));
|
||||||
|
const mainTab = terminalTabs.find((tab: any) => tab.id === currentTab);
|
||||||
|
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: isFileManagerTab ? 0 : 2,
|
||||||
|
left: isFileManagerTab ? 0 : 2,
|
||||||
|
right: isFileManagerTab ? 0 : 2,
|
||||||
|
bottom: isFileManagerTab ? 0 : 2,
|
||||||
|
zIndex: 20,
|
||||||
|
display: 'block',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
opacity: ready ? 1 : 0
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
layoutTabs.forEach((t: any) => {
|
||||||
|
const rect = panelRects[String(t.id)];
|
||||||
|
const parentRect = containerRef.current?.getBoundingClientRect();
|
||||||
|
if (rect && parentRect) {
|
||||||
|
styles[t.id] = {
|
||||||
|
position: 'absolute',
|
||||||
|
top: (rect.top - parentRect.top) + HEADER_H + 2,
|
||||||
|
left: (rect.left - parentRect.left) + 2,
|
||||||
|
width: rect.width - 4,
|
||||||
|
height: rect.height - HEADER_H - 4,
|
||||||
|
zIndex: 20,
|
||||||
|
display: 'block',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
opacity: ready ? 1 : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{position: 'absolute', inset: 0, zIndex: 1}}>
|
||||||
|
{terminalTabs.map((t: any) => {
|
||||||
|
const hasStyle = !!styles[t.id];
|
||||||
|
const isVisible = hasStyle || (allSplitScreenTab.length === 0 && t.id === currentTab);
|
||||||
|
|
||||||
|
const finalStyle: React.CSSProperties = hasStyle
|
||||||
|
? {...styles[t.id], overflow: 'hidden'}
|
||||||
|
: {
|
||||||
|
position: 'absolute', inset: 0, visibility: 'hidden', pointerEvents: 'none', zIndex: 0,
|
||||||
|
} as React.CSSProperties;
|
||||||
|
|
||||||
|
const effectiveVisible = isVisible && ready;
|
||||||
|
return (
|
||||||
|
<div key={t.id} style={finalStyle}>
|
||||||
|
<div className="absolute inset-0 rounded-md bg-[#18181b]">
|
||||||
|
{t.type === 'terminal' ? (
|
||||||
|
<Terminal
|
||||||
|
ref={t.terminalRef}
|
||||||
|
hostConfig={t.hostConfig}
|
||||||
|
isVisible={effectiveVisible}
|
||||||
|
title={t.title}
|
||||||
|
showTitle={false}
|
||||||
|
splitScreen={allSplitScreenTab.length > 0}
|
||||||
|
/>
|
||||||
|
) : t.type === 'server' ? (
|
||||||
|
<ServerView
|
||||||
|
hostConfig={t.hostConfig}
|
||||||
|
title={t.title}
|
||||||
|
isVisible={effectiveVisible}
|
||||||
|
isTopbarOpen={isTopbarOpen}
|
||||||
|
embedded
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FileManager
|
||||||
|
embedded
|
||||||
|
initialHost={t.hostConfig}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ResetButton = ({onClick}: { onClick: () => void }) => (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onClick}
|
||||||
|
aria-label="Reset split sizes"
|
||||||
|
className="absolute top-0 right-0 h-[28px] w-[28px] !rounded-none border-l-1 border-b-1 border-[#222224] bg-[#1b1b1e] hover:bg-[#232327] text-white flex items-center justify-center p-0"
|
||||||
|
>
|
||||||
|
<RefreshCcw className="h-4 w-4"/>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setResetKey((k) => k + 1);
|
||||||
|
requestAnimationFrame(() => scheduleMeasureAndFit());
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSplitOverlays = () => {
|
||||||
|
const splitTabs = terminalTabs.filter((tab: any) => allSplitScreenTab.includes(tab.id));
|
||||||
|
const mainTab = terminalTabs.find((tab: any) => tab.id === currentTab);
|
||||||
|
const layoutTabs = [mainTab, ...splitTabs.filter((t: any) => t && t.id !== (mainTab && (mainTab as any).id))].filter(Boolean) as any[];
|
||||||
|
if (allSplitScreenTab.length === 0) return null;
|
||||||
|
|
||||||
|
const handleStyle = {pointerEvents: 'auto', zIndex: 12, background: '#303032'} as React.CSSProperties;
|
||||||
|
const commonGroupProps = {onLayout: scheduleMeasureAndFit, onResize: scheduleMeasureAndFit} as any;
|
||||||
|
|
||||||
|
if (layoutTabs.length === 2) {
|
||||||
|
const [a, b] = layoutTabs as any[];
|
||||||
|
return (
|
||||||
|
<div style={{position: 'absolute', inset: 0, zIndex: 10, pointerEvents: 'none'}}>
|
||||||
|
<ResizablePrimitive.PanelGroup key={resetKey} direction="horizontal"
|
||||||
|
className="h-full w-full" {...commonGroupProps}>
|
||||||
|
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
|
||||||
|
id={`panel-${a.id}`} order={1}>
|
||||||
|
<div ref={el => {
|
||||||
|
panelRefs.current[String(a.id)] = el;
|
||||||
|
}} style={{
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
background: 'transparent',
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: '#1b1b1e',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 13,
|
||||||
|
height: HEADER_H,
|
||||||
|
lineHeight: `${HEADER_H}px`,
|
||||||
|
padding: '0 10px',
|
||||||
|
borderBottom: '1px solid #222224',
|
||||||
|
letterSpacing: 1,
|
||||||
|
margin: 0,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
zIndex: 11,
|
||||||
|
position: 'relative'
|
||||||
|
}}>{a.title}</div>
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
<ResizableHandle style={handleStyle}/>
|
||||||
|
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
|
||||||
|
id={`panel-${b.id}`} order={2}>
|
||||||
|
<div ref={el => {
|
||||||
|
panelRefs.current[String(b.id)] = el;
|
||||||
|
}} style={{
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
background: 'transparent',
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: '#1b1b1e',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 13,
|
||||||
|
height: HEADER_H,
|
||||||
|
lineHeight: `${HEADER_H}px`,
|
||||||
|
padding: '0 10px',
|
||||||
|
borderBottom: '1px solid #222224',
|
||||||
|
letterSpacing: 1,
|
||||||
|
margin: 0,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
zIndex: 11,
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
{b.title}
|
||||||
|
<ResetButton onClick={handleReset}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePrimitive.PanelGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (layoutTabs.length === 3) {
|
||||||
|
const [a, b, c] = layoutTabs as any[];
|
||||||
|
return (
|
||||||
|
<div style={{position: 'absolute', inset: 0, zIndex: 10, pointerEvents: 'none'}}>
|
||||||
|
<ResizablePrimitive.PanelGroup key={resetKey} direction="vertical" className="h-full w-full"
|
||||||
|
id="main-vertical" {...commonGroupProps}>
|
||||||
|
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
|
||||||
|
id="top-panel" order={1}>
|
||||||
|
<ResizablePanelGroup key={`top-${resetKey}`} direction="horizontal"
|
||||||
|
className="h-full w-full" id="top-horizontal" {...commonGroupProps}>
|
||||||
|
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
|
||||||
|
id={`panel-${a.id}`} order={1}>
|
||||||
|
<div ref={el => {
|
||||||
|
panelRefs.current[String(a.id)] = el;
|
||||||
|
}} style={{
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: '#1b1b1e',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 13,
|
||||||
|
height: HEADER_H,
|
||||||
|
lineHeight: `${HEADER_H}px`,
|
||||||
|
padding: '0 10px',
|
||||||
|
borderBottom: '1px solid #222224',
|
||||||
|
letterSpacing: 1,
|
||||||
|
margin: 0,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
zIndex: 11,
|
||||||
|
position: 'relative'
|
||||||
|
}}>{a.title}</div>
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
<ResizableHandle style={handleStyle}/>
|
||||||
|
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
|
||||||
|
id={`panel-${b.id}`} order={2}>
|
||||||
|
<div ref={el => {
|
||||||
|
panelRefs.current[String(b.id)] = el;
|
||||||
|
}} style={{
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: '#1b1b1e',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 13,
|
||||||
|
height: HEADER_H,
|
||||||
|
lineHeight: `${HEADER_H}px`,
|
||||||
|
padding: '0 10px',
|
||||||
|
borderBottom: '1px solid #222224',
|
||||||
|
letterSpacing: 1,
|
||||||
|
margin: 0,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
zIndex: 11,
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
{b.title}
|
||||||
|
<ResetButton onClick={handleReset}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</ResizablePanel>
|
||||||
|
<ResizableHandle style={handleStyle}/>
|
||||||
|
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
|
||||||
|
id="bottom-panel" order={2}>
|
||||||
|
<div ref={el => {
|
||||||
|
panelRefs.current[String(c.id)] = el;
|
||||||
|
}} style={{
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: '#1b1b1e',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 13,
|
||||||
|
height: HEADER_H,
|
||||||
|
lineHeight: `${HEADER_H}px`,
|
||||||
|
padding: '0 10px',
|
||||||
|
borderBottom: '1px solid #222224',
|
||||||
|
letterSpacing: 1,
|
||||||
|
margin: 0,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
zIndex: 11,
|
||||||
|
position: 'relative'
|
||||||
|
}}>{c.title}</div>
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePrimitive.PanelGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (layoutTabs.length === 4) {
|
||||||
|
const [a, b, c, d] = layoutTabs as any[];
|
||||||
|
return (
|
||||||
|
<div style={{position: 'absolute', inset: 0, zIndex: 10, pointerEvents: 'none'}}>
|
||||||
|
<ResizablePrimitive.PanelGroup key={resetKey} direction="vertical" className="h-full w-full"
|
||||||
|
id="main-vertical" {...commonGroupProps}>
|
||||||
|
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
|
||||||
|
id="top-panel" order={1}>
|
||||||
|
<ResizablePanelGroup key={`top-${resetKey}`} direction="horizontal"
|
||||||
|
className="h-full w-full" id="top-horizontal" {...commonGroupProps}>
|
||||||
|
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
|
||||||
|
id={`panel-${a.id}`} order={1}>
|
||||||
|
<div ref={el => {
|
||||||
|
panelRefs.current[String(a.id)] = el;
|
||||||
|
}} style={{
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: '#1b1b1e',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 13,
|
||||||
|
height: HEADER_H,
|
||||||
|
lineHeight: `${HEADER_H}px`,
|
||||||
|
padding: '0 10px',
|
||||||
|
borderBottom: '1px solid #222224',
|
||||||
|
letterSpacing: 1,
|
||||||
|
margin: 0,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
zIndex: 11,
|
||||||
|
position: 'relative'
|
||||||
|
}}>{a.title}</div>
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
<ResizableHandle style={handleStyle}/>
|
||||||
|
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
|
||||||
|
id={`panel-${b.id}`} order={2}>
|
||||||
|
<div ref={el => {
|
||||||
|
panelRefs.current[String(b.id)] = el;
|
||||||
|
}} style={{
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: '#1b1b1e',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 13,
|
||||||
|
height: HEADER_H,
|
||||||
|
lineHeight: `${HEADER_H}px`,
|
||||||
|
padding: '0 10px',
|
||||||
|
borderBottom: '1px solid #222224',
|
||||||
|
letterSpacing: 1,
|
||||||
|
margin: 0,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
zIndex: 11,
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
{b.title}
|
||||||
|
<ResetButton onClick={handleReset}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</ResizablePanel>
|
||||||
|
<ResizableHandle style={handleStyle}/>
|
||||||
|
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
|
||||||
|
id="bottom-panel" order={2}>
|
||||||
|
<ResizablePanelGroup key={`bottom-${resetKey}`} direction="horizontal"
|
||||||
|
className="h-full w-full" id="bottom-horizontal" {...commonGroupProps}>
|
||||||
|
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
|
||||||
|
id={`panel-${c.id}`} order={1}>
|
||||||
|
<div ref={el => {
|
||||||
|
panelRefs.current[String(c.id)] = el;
|
||||||
|
}} style={{
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: '#1b1b1e',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 13,
|
||||||
|
height: HEADER_H,
|
||||||
|
lineHeight: `${HEADER_H}px`,
|
||||||
|
padding: '0 10px',
|
||||||
|
borderBottom: '1px solid #222224',
|
||||||
|
letterSpacing: 1,
|
||||||
|
margin: 0,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
zIndex: 11,
|
||||||
|
position: 'relative'
|
||||||
|
}}>{c.title}</div>
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
<ResizableHandle style={handleStyle}/>
|
||||||
|
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
|
||||||
|
id={`panel-${d.id}`} order={2}>
|
||||||
|
<div ref={el => {
|
||||||
|
panelRefs.current[String(d.id)] = el;
|
||||||
|
}} style={{
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: '#1b1b1e',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 13,
|
||||||
|
height: HEADER_H,
|
||||||
|
lineHeight: `${HEADER_H}px`,
|
||||||
|
padding: '0 10px',
|
||||||
|
borderBottom: '1px solid #222224',
|
||||||
|
letterSpacing: 1,
|
||||||
|
margin: 0,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
zIndex: 11,
|
||||||
|
position: 'relative'
|
||||||
|
}}>{d.title}</div>
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePrimitive.PanelGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentTabData = tabs.find((tab: any) => tab.id === currentTab);
|
||||||
|
const isFileManager = currentTabData?.type === 'file_manager';
|
||||||
|
const isSplitScreen = allSplitScreenTab.length > 0;
|
||||||
|
|
||||||
|
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||||
|
const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8;
|
||||||
|
const bottomMarginPx = 8;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="border-2 border-[#303032] rounded-lg overflow-hidden overflow-x-hidden"
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
background: (isFileManager && !isSplitScreen) ? '#09090b' : '#18181b',
|
||||||
|
marginLeft: leftMarginPx,
|
||||||
|
marginRight: 17,
|
||||||
|
marginTop: topMarginPx,
|
||||||
|
marginBottom: bottomMarginPx,
|
||||||
|
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderTerminalsLayer()}
|
||||||
|
{renderSplitOverlays()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
src/ui/Navigation/Hosts/FolderCard.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import React, {useState} from "react";
|
||||||
|
import {CardTitle} from "@/components/ui/card.tsx";
|
||||||
|
import {ChevronDown, Folder} from "lucide-react";
|
||||||
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
|
import {Host} from "@/ui/Navigation/Hosts/Host.tsx";
|
||||||
|
import {Separator} from "@/components/ui/separator.tsx";
|
||||||
|
|
||||||
|
interface SSHHost {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
folder: string;
|
||||||
|
tags: string[];
|
||||||
|
pin: boolean;
|
||||||
|
authType: string;
|
||||||
|
password?: string;
|
||||||
|
key?: string;
|
||||||
|
keyPassword?: string;
|
||||||
|
keyType?: string;
|
||||||
|
enableTerminal: boolean;
|
||||||
|
enableTunnel: boolean;
|
||||||
|
enableFileManager: boolean;
|
||||||
|
defaultPath: string;
|
||||||
|
tunnelConnections: any[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FolderCardProps {
|
||||||
|
folderName: string;
|
||||||
|
hosts: SSHHost[];
|
||||||
|
isFirst: boolean;
|
||||||
|
isLast: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FolderCard({folderName, hosts, isFirst, isLast}: FolderCardProps): React.ReactElement {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
|
|
||||||
|
const toggleExpanded = () => {
|
||||||
|
setIsExpanded(!isExpanded);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[#0e0e10] border-2 border-[#303032] rounded-lg overflow-hidden"
|
||||||
|
style={{padding: '0', margin: '0'}}>
|
||||||
|
<div className={`px-4 py-3 relative ${isExpanded ? 'border-b-2' : ''} bg-[#131316]`}>
|
||||||
|
<div className="flex gap-2 pr-10">
|
||||||
|
<div className="flex-shrink-0 flex items-center">
|
||||||
|
<Folder size={16} strokeWidth={3}/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<CardTitle className="mb-0 leading-tight break-words text-md">{folderName}</CardTitle>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-[28px] h-[28px] absolute right-4 top-1/2 -translate-y-1/2 flex-shrink-0"
|
||||||
|
onClick={toggleExpanded}
|
||||||
|
>
|
||||||
|
<ChevronDown className={`h-4 w-4 transition-transform ${isExpanded ? '' : 'rotate-180'}`}/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="flex flex-col p-2 gap-y-3">
|
||||||
|
{hosts.map((host, index) => (
|
||||||
|
<React.Fragment key={`${folderName}-host-${host.id}-${host.name || host.ip}`}>
|
||||||
|
<Host host={host}/>
|
||||||
|
{index < hosts.length - 1 && (
|
||||||
|
<div className="relative -mx-2">
|
||||||
|
<Separator className="p-0.25 absolute inset-x-0"/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
111
src/ui/Navigation/Hosts/Host.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import React, {useEffect, useState} from "react";
|
||||||
|
import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status";
|
||||||
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
|
import {ButtonGroup} from "@/components/ui/button-group.tsx";
|
||||||
|
import {Server, Terminal} from "lucide-react";
|
||||||
|
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
|
||||||
|
import {getServerStatusById} from "@/ui/main-axios.ts";
|
||||||
|
|
||||||
|
interface SSHHost {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
folder: string;
|
||||||
|
tags: string[];
|
||||||
|
pin: boolean;
|
||||||
|
authType: string;
|
||||||
|
password?: string;
|
||||||
|
key?: string;
|
||||||
|
keyPassword?: string;
|
||||||
|
keyType?: string;
|
||||||
|
enableTerminal: boolean;
|
||||||
|
enableTunnel: boolean;
|
||||||
|
enableFileManager: boolean;
|
||||||
|
defaultPath: string;
|
||||||
|
tunnelConnections: any[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HostProps {
|
||||||
|
host: SSHHost;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Host({host}: HostProps): React.ReactElement {
|
||||||
|
const {addTab} = useTabs();
|
||||||
|
const [serverStatus, setServerStatus] = useState<'online' | 'offline'>('offline');
|
||||||
|
const tags = Array.isArray(host.tags) ? host.tags : [];
|
||||||
|
const hasTags = tags.length > 0;
|
||||||
|
|
||||||
|
const title = host.name?.trim() ? host.name : `${host.username}@${host.ip}:${host.port}`;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
let intervalId: number | undefined;
|
||||||
|
|
||||||
|
const fetchStatus = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getServerStatusById(host.id);
|
||||||
|
if (!cancelled) {
|
||||||
|
setServerStatus(res?.status === 'online' ? 'online' : 'offline');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setServerStatus('offline');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchStatus();
|
||||||
|
intervalId = window.setInterval(fetchStatus, 60_000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (intervalId) window.clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, [host.id]);
|
||||||
|
|
||||||
|
const handleTerminalClick = () => {
|
||||||
|
addTab({type: 'terminal', title, hostConfig: host});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleServerClick = () => {
|
||||||
|
addTab({type: 'server', title, hostConfig: host});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Status status={serverStatus} className="!bg-transparent !p-0.75 flex-shrink-0">
|
||||||
|
<StatusIndicator/>
|
||||||
|
</Status>
|
||||||
|
<p className="font-semibold flex-1 min-w-0 break-words text-sm">
|
||||||
|
{host.name || host.ip}
|
||||||
|
</p>
|
||||||
|
<ButtonGroup className="flex-shrink-0">
|
||||||
|
<Button variant="outline" className="!px-2 border-1 border-[#303032]" onClick={handleServerClick}>
|
||||||
|
<Server/>
|
||||||
|
</Button>
|
||||||
|
{host.enableTerminal && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="!px-2 border-1 border-[#303032]"
|
||||||
|
onClick={handleTerminalClick}
|
||||||
|
>
|
||||||
|
<Terminal/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</ButtonGroup>
|
||||||
|
</div>
|
||||||
|
{hasTags && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mt-1">
|
||||||
|
{tags.map((tag: string) => (
|
||||||
|
<div key={tag} className="bg-[#18181b] border-1 border-[#303032] pl-2 pr-2 rounded-[10px]">
|
||||||
|
<p className="text-sm">{tag}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
678
src/ui/Navigation/LeftSidebar.tsx
Normal file
@@ -0,0 +1,678 @@
|
|||||||
|
import React, {useState} from 'react';
|
||||||
|
import {
|
||||||
|
Computer,
|
||||||
|
Server,
|
||||||
|
File,
|
||||||
|
Hammer, ChevronUp, User2, HardDrive, Trash2, Users, Shield, Settings, Menu, ChevronRight
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent, SidebarFooter,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem, SidebarProvider, SidebarInset, SidebarHeader,
|
||||||
|
} from "@/components/ui/sidebar.tsx"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Separator,
|
||||||
|
} from "@/components/ui/separator.tsx"
|
||||||
|
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@radix-ui/react-dropdown-menu";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetFooter,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
|
import {Checkbox} from "@/components/ui/checkbox.tsx";
|
||||||
|
import {Input} from "@/components/ui/input.tsx";
|
||||||
|
import {Label} from "@/components/ui/label.tsx";
|
||||||
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
|
import {Alert, AlertTitle, AlertDescription} from "@/components/ui/alert.tsx";
|
||||||
|
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table.tsx";
|
||||||
|
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;
|
||||||
|
name: string;
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
folder: string;
|
||||||
|
tags: string[];
|
||||||
|
pin: boolean;
|
||||||
|
authType: string;
|
||||||
|
password?: string;
|
||||||
|
key?: string;
|
||||||
|
keyPassword?: string;
|
||||||
|
keyType?: string;
|
||||||
|
enableTerminal: boolean;
|
||||||
|
enableTunnel: boolean;
|
||||||
|
enableFileManager: boolean;
|
||||||
|
defaultPath: string;
|
||||||
|
tunnelConnections: any[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
onSelectView: (view: string) => void;
|
||||||
|
getView?: () => string;
|
||||||
|
disabled?: boolean;
|
||||||
|
isAdmin?: boolean;
|
||||||
|
username?: string | null;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCookie(name: string) {
|
||||||
|
return document.cookie.split('; ').reduce((r, v) => {
|
||||||
|
const parts = v.split('=');
|
||||||
|
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
|
||||||
|
}, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export function LeftSidebar({
|
||||||
|
onSelectView,
|
||||||
|
getView,
|
||||||
|
disabled,
|
||||||
|
isAdmin,
|
||||||
|
username,
|
||||||
|
children,
|
||||||
|
}: SidebarProps): React.ReactElement {
|
||||||
|
const [adminSheetOpen, setAdminSheetOpen] = React.useState(false);
|
||||||
|
|
||||||
|
const [deleteAccountOpen, setDeleteAccountOpen] = React.useState(false);
|
||||||
|
const [deletePassword, setDeletePassword] = React.useState("");
|
||||||
|
const [deleteLoading, setDeleteLoading] = React.useState(false);
|
||||||
|
const [deleteError, setDeleteError] = React.useState<string | null>(null);
|
||||||
|
const [adminCount, setAdminCount] = React.useState(0);
|
||||||
|
|
||||||
|
const [users, setUsers] = React.useState<Array<{
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
is_admin: boolean;
|
||||||
|
is_oidc: boolean;
|
||||||
|
}>>([]);
|
||||||
|
const [newAdminUsername, setNewAdminUsername] = React.useState("");
|
||||||
|
const [usersLoading, setUsersLoading] = React.useState(false);
|
||||||
|
const [makeAdminLoading, setMakeAdminLoading] = React.useState(false);
|
||||||
|
const [makeAdminError, setMakeAdminError] = React.useState<string | null>(null);
|
||||||
|
const [makeAdminSuccess, setMakeAdminSuccess] = React.useState<string | null>(null);
|
||||||
|
const [oidcConfig, setOidcConfig] = React.useState<any>(null);
|
||||||
|
|
||||||
|
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(true);
|
||||||
|
|
||||||
|
const {tabs: tabList, addTab, setCurrentTab, allSplitScreenTab} = useTabs() as any;
|
||||||
|
const isSplitScreenActive = Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0;
|
||||||
|
const sshManagerTab = tabList.find((t) => t.type === 'ssh_manager');
|
||||||
|
const openSshManagerTab = () => {
|
||||||
|
if (sshManagerTab || isSplitScreenActive) return;
|
||||||
|
const id = addTab({type: 'ssh_manager', title: 'SSH Manager'} as any);
|
||||||
|
setCurrentTab(id);
|
||||||
|
};
|
||||||
|
const adminTab = tabList.find((t) => t.type === 'admin');
|
||||||
|
const openAdminTab = () => {
|
||||||
|
if (isSplitScreenActive) return;
|
||||||
|
if (adminTab) {
|
||||||
|
setCurrentTab(adminTab.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = addTab({type: 'admin', title: 'Admin'} as any);
|
||||||
|
setCurrentTab(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
||||||
|
const [hostsLoading, setHostsLoading] = useState(false);
|
||||||
|
const [hostsError, setHostsError] = useState<string | null>(null);
|
||||||
|
const prevHostsRef = React.useRef<SSHHost[]>([]);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (adminSheetOpen) {
|
||||||
|
const jwt = getCookie("jwt");
|
||||||
|
if (jwt && isAdmin) {
|
||||||
|
getOIDCConfig().then(res => {
|
||||||
|
if (res) {
|
||||||
|
setOidcConfig(res);
|
||||||
|
}
|
||||||
|
}).catch((error) => {
|
||||||
|
});
|
||||||
|
fetchUsers();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const jwt = getCookie("jwt");
|
||||||
|
if (jwt && isAdmin) {
|
||||||
|
fetchAdminCount();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [adminSheetOpen, isAdmin]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isAdmin) {
|
||||||
|
setAdminSheetOpen(false);
|
||||||
|
setUsers([]);
|
||||||
|
setAdminCount(0);
|
||||||
|
}
|
||||||
|
}, [isAdmin]);
|
||||||
|
|
||||||
|
const fetchHosts = React.useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const newHosts = await getSSHHosts();
|
||||||
|
const prevHosts = prevHostsRef.current;
|
||||||
|
|
||||||
|
const existingHostsMap = new Map(prevHosts.map(h => [h.id, h]));
|
||||||
|
const newHostsMap = new Map(newHosts.map(h => [h.id, h]));
|
||||||
|
|
||||||
|
let hasChanges = false;
|
||||||
|
|
||||||
|
if (newHosts.length !== prevHosts.length) {
|
||||||
|
hasChanges = true;
|
||||||
|
} else {
|
||||||
|
for (const [id, newHost] of newHostsMap) {
|
||||||
|
const existingHost = existingHostsMap.get(id);
|
||||||
|
if (!existingHost) {
|
||||||
|
hasChanges = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
newHost.name !== existingHost.name ||
|
||||||
|
newHost.folder !== existingHost.folder ||
|
||||||
|
newHost.ip !== existingHost.ip ||
|
||||||
|
newHost.port !== existingHost.port ||
|
||||||
|
newHost.username !== existingHost.username ||
|
||||||
|
newHost.pin !== existingHost.pin ||
|
||||||
|
newHost.enableTerminal !== existingHost.enableTerminal ||
|
||||||
|
JSON.stringify(newHost.tags) !== JSON.stringify(existingHost.tags)
|
||||||
|
) {
|
||||||
|
hasChanges = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasChanges) {
|
||||||
|
setTimeout(() => {
|
||||||
|
setHosts(newHosts);
|
||||||
|
prevHostsRef.current = newHosts;
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setHostsError('Failed to load hosts');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
fetchHosts();
|
||||||
|
const interval = setInterval(fetchHosts, 300000); // 5 minutes instead of 10 seconds
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [fetchHosts]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleHostsChanged = () => {
|
||||||
|
fetchHosts();
|
||||||
|
};
|
||||||
|
window.addEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
|
||||||
|
return () => window.removeEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
|
||||||
|
}, [fetchHosts]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handler = setTimeout(() => setDebouncedSearch(search), 200);
|
||||||
|
return () => clearTimeout(handler);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
const filteredHosts = React.useMemo(() => {
|
||||||
|
if (!debouncedSearch.trim()) return hosts;
|
||||||
|
const q = debouncedSearch.trim().toLowerCase();
|
||||||
|
return hosts.filter(h => {
|
||||||
|
const searchableText = [
|
||||||
|
h.name || '',
|
||||||
|
h.username,
|
||||||
|
h.ip,
|
||||||
|
h.folder || '',
|
||||||
|
...(h.tags || []),
|
||||||
|
h.authType,
|
||||||
|
h.defaultPath || ''
|
||||||
|
].join(' ').toLowerCase();
|
||||||
|
return searchableText.includes(q);
|
||||||
|
});
|
||||||
|
}, [hosts, debouncedSearch]);
|
||||||
|
|
||||||
|
const hostsByFolder = React.useMemo(() => {
|
||||||
|
const map: Record<string, SSHHost[]> = {};
|
||||||
|
filteredHosts.forEach(h => {
|
||||||
|
const folder = h.folder && h.folder.trim() ? h.folder : 'No Folder';
|
||||||
|
if (!map[folder]) map[folder] = [];
|
||||||
|
map[folder].push(h);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [filteredHosts]);
|
||||||
|
|
||||||
|
const sortedFolders = React.useMemo(() => {
|
||||||
|
const folders = Object.keys(hostsByFolder);
|
||||||
|
folders.sort((a, b) => {
|
||||||
|
if (a === 'No Folder') return -1;
|
||||||
|
if (b === 'No Folder') return 1;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
return folders;
|
||||||
|
}, [hostsByFolder]);
|
||||||
|
|
||||||
|
const getSortedHosts = React.useCallback((arr: SSHHost[]) => {
|
||||||
|
const pinned = arr.filter(h => h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||||
|
const rest = arr.filter(h => !h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||||
|
return [...pinned, ...rest];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDeleteAccount = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDeleteLoading(true);
|
||||||
|
setDeleteError(null);
|
||||||
|
|
||||||
|
if (!deletePassword.trim()) {
|
||||||
|
setDeleteError("Password is required");
|
||||||
|
setDeleteLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jwt = getCookie("jwt");
|
||||||
|
try {
|
||||||
|
await deleteAccount(deletePassword);
|
||||||
|
|
||||||
|
handleLogout();
|
||||||
|
} catch (err: any) {
|
||||||
|
setDeleteError(err?.response?.data?.error || "Failed to delete account");
|
||||||
|
setDeleteLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchUsers = async () => {
|
||||||
|
const jwt = getCookie("jwt");
|
||||||
|
|
||||||
|
if (!jwt || !isAdmin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUsersLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await getUserList();
|
||||||
|
setUsers(response.users);
|
||||||
|
|
||||||
|
const adminUsers = response.users.filter((user: any) => user.is_admin);
|
||||||
|
setAdminCount(adminUsers.length);
|
||||||
|
} catch (err: any) {
|
||||||
|
} finally {
|
||||||
|
setUsersLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchAdminCount = async () => {
|
||||||
|
const jwt = getCookie("jwt");
|
||||||
|
|
||||||
|
if (!jwt || !isAdmin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await getUserList();
|
||||||
|
const adminUsers = response.users.filter((user: any) => user.is_admin);
|
||||||
|
setAdminCount(adminUsers.length);
|
||||||
|
} catch (err: any) {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeUserAdmin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newAdminUsername.trim()) return;
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMakeAdminLoading(true);
|
||||||
|
setMakeAdminError(null);
|
||||||
|
setMakeAdminSuccess(null);
|
||||||
|
|
||||||
|
const jwt = getCookie("jwt");
|
||||||
|
try {
|
||||||
|
await makeUserAdmin(newAdminUsername.trim());
|
||||||
|
setMakeAdminSuccess(`User ${newAdminUsername} is now an admin`);
|
||||||
|
setNewAdminUsername("");
|
||||||
|
fetchUsers();
|
||||||
|
} catch (err: any) {
|
||||||
|
setMakeAdminError(err?.response?.data?.error || "Failed to make user admin");
|
||||||
|
} finally {
|
||||||
|
setMakeAdminLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeAdminStatus = async (username: string) => {
|
||||||
|
if (!confirm(`Are you sure you want to remove admin status from ${username}?`)) return;
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jwt = getCookie("jwt");
|
||||||
|
try {
|
||||||
|
await removeAdminStatus(username);
|
||||||
|
fetchUsers();
|
||||||
|
} catch (err: any) {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteUser = async (username: string) => {
|
||||||
|
if (!confirm(`Are you sure you want to delete user ${username}? This action cannot be undone.`)) return;
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jwt = getCookie("jwt");
|
||||||
|
try {
|
||||||
|
await deleteUser(username);
|
||||||
|
fetchUsers();
|
||||||
|
} catch (err: any) {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-svh">
|
||||||
|
<SidebarProvider open={isSidebarOpen}>
|
||||||
|
<Sidebar variant="floating" className="">
|
||||||
|
<SidebarHeader>
|
||||||
|
<SidebarGroupLabel className="text-lg font-bold text-white">
|
||||||
|
Termix
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||||
|
className="w-[28px] h-[28px] absolute right-5"
|
||||||
|
>
|
||||||
|
<Menu className="h-4 w-4"/>
|
||||||
|
</Button>
|
||||||
|
</SidebarGroupLabel>
|
||||||
|
</SidebarHeader>
|
||||||
|
<Separator className="p-0.25"/>
|
||||||
|
<SidebarContent>
|
||||||
|
<SidebarGroup className="!m-0 !p-0 !-mb-2">
|
||||||
|
<Button className="m-2 flex flex-row font-semibold" variant="outline"
|
||||||
|
onClick={openSshManagerTab} disabled={!!sshManagerTab || isSplitScreenActive}
|
||||||
|
title={sshManagerTab ? 'SSH Manager already open' : isSplitScreenActive ? 'Disabled during split screen' : undefined}>
|
||||||
|
<HardDrive strokeWidth="2.5"/>
|
||||||
|
Host Manager
|
||||||
|
</Button>
|
||||||
|
</SidebarGroup>
|
||||||
|
<Separator className="p-0.25"/>
|
||||||
|
<SidebarGroup className="flex flex-col gap-y-2 !-mt-2">
|
||||||
|
<div className="bg-[#131316] rounded-lg">
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
placeholder="Search hosts by any info..."
|
||||||
|
className="w-full h-8 text-sm border-2 border-[#272728] rounded-lg"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hostsError && (
|
||||||
|
<div className="px-1">
|
||||||
|
<div
|
||||||
|
className="text-xs text-red-500 bg-red-500/10 rounded-lg px-2 py-1 border w-full">
|
||||||
|
{hostsError}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hostsLoading && (
|
||||||
|
<div className="px-4 pb-2">
|
||||||
|
<div className="text-xs text-muted-foreground text-center">
|
||||||
|
Loading hosts...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sortedFolders.map((folder, idx) => (
|
||||||
|
<FolderCard
|
||||||
|
key={`folder-${folder}-${hostsByFolder[folder]?.length || 0}`}
|
||||||
|
folderName={folder}
|
||||||
|
hosts={getSortedHosts(hostsByFolder[folder])}
|
||||||
|
isFirst={idx === 0}
|
||||||
|
isLast={idx === sortedFolders.length - 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SidebarGroup>
|
||||||
|
</SidebarContent>
|
||||||
|
<Separator className="p-0.25 mt-1 mb-1"/>
|
||||||
|
<SidebarFooter>
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<SidebarMenuButton
|
||||||
|
className="data-[state=open]:opacity-90 w-full"
|
||||||
|
style={{width: '100%'}}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<User2/> {username ? username : 'Signed out'}
|
||||||
|
<ChevronUp className="ml-auto"/>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
side="top"
|
||||||
|
align="start"
|
||||||
|
sideOffset={6}
|
||||||
|
className="min-w-[var(--radix-popper-anchor-width)] bg-sidebar-accent text-sidebar-accent-foreground border border-border rounded-md shadow-2xl p-1"
|
||||||
|
>
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
|
||||||
|
onClick={() => {
|
||||||
|
if (isAdmin) openAdminTab();
|
||||||
|
}}>
|
||||||
|
<span>Admin Settings</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
|
||||||
|
onClick={handleLogout}>
|
||||||
|
<span>Sign out</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
|
||||||
|
onClick={() => setDeleteAccountOpen(true)}
|
||||||
|
disabled={isAdmin && adminCount <= 1}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={isAdmin && adminCount <= 1 ? "text-muted-foreground" : "text-red-400"}>
|
||||||
|
Delete Account
|
||||||
|
{isAdmin && adminCount <= 1 && " (Last Admin)"}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarFooter>
|
||||||
|
|
||||||
|
|
||||||
|
</Sidebar>
|
||||||
|
<SidebarInset>
|
||||||
|
{children}
|
||||||
|
</SidebarInset>
|
||||||
|
</SidebarProvider>
|
||||||
|
|
||||||
|
{!isSidebarOpen && (
|
||||||
|
<div
|
||||||
|
onClick={() => setIsSidebarOpen(true)}
|
||||||
|
className="absolute top-0 left-0 w-[10px] h-full bg-[#18181b] cursor-pointer z-20 flex items-center justify-center rounded-tr-md rounded-br-md">
|
||||||
|
<ChevronRight size={10}/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deleteAccountOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[999999] flex"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
zIndex: 999999,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
isolation: 'isolate',
|
||||||
|
transform: 'translateZ(0)',
|
||||||
|
willChange: 'z-index'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-[400px] h-full bg-[#18181b] border-r-2 border-[#303032] flex flex-col shadow-2xl"
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#18181b',
|
||||||
|
boxShadow: '4px 0 20px rgba(0, 0, 0, 0.5)',
|
||||||
|
zIndex: 9999999,
|
||||||
|
position: 'relative',
|
||||||
|
isolation: 'isolate',
|
||||||
|
transform: 'translateZ(0)'
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-[#303032]">
|
||||||
|
<h2 className="text-lg font-semibold text-white">Delete Account</h2>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteAccountOpen(false);
|
||||||
|
setDeletePassword("");
|
||||||
|
setDeleteError(null);
|
||||||
|
}}
|
||||||
|
className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center"
|
||||||
|
title="Close Delete Account"
|
||||||
|
>
|
||||||
|
<span className="text-lg font-bold leading-none">×</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-sm text-gray-300">
|
||||||
|
This action cannot be undone. This will permanently delete your account and all
|
||||||
|
associated data.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>Warning</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Deleting your account will remove all your data including SSH hosts,
|
||||||
|
configurations, and settings.
|
||||||
|
This action is irreversible.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{deleteError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>Error</AlertTitle>
|
||||||
|
<AlertDescription>{deleteError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleDeleteAccount} className="space-y-4">
|
||||||
|
{isAdmin && adminCount <= 1 && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>Cannot Delete Account</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
You are the last admin user. You cannot delete your account as this
|
||||||
|
would leave the system without any administrators.
|
||||||
|
Please make another user an admin first, or contact system support.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="delete-password">Confirm Password</Label>
|
||||||
|
<Input
|
||||||
|
id="delete-password"
|
||||||
|
type="password"
|
||||||
|
value={deletePassword}
|
||||||
|
onChange={(e) => setDeletePassword(e.target.value)}
|
||||||
|
placeholder="Enter your password to confirm"
|
||||||
|
required
|
||||||
|
disabled={isAdmin && adminCount <= 1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
|
className="flex-1"
|
||||||
|
disabled={deleteLoading || !deletePassword.trim() || (isAdmin && adminCount <= 1)}
|
||||||
|
>
|
||||||
|
{deleteLoading ? "Deleting..." : "Delete Account"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteAccountOpen(false);
|
||||||
|
setDeletePassword("");
|
||||||
|
setDeleteError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteAccountOpen(false);
|
||||||
|
setDeletePassword("");
|
||||||
|
setDeleteError(null);
|
||||||
|
}}
|
||||||
|
style={{cursor: 'pointer'}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
140
src/ui/Navigation/Tabs/Tab.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {ButtonGroup} from "@/components/ui/button-group.tsx";
|
||||||
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
|
import {
|
||||||
|
Home,
|
||||||
|
SeparatorVertical,
|
||||||
|
X,
|
||||||
|
Terminal as TerminalIcon,
|
||||||
|
Server as ServerIcon,
|
||||||
|
Folder as FolderIcon
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface TabProps {
|
||||||
|
tabType: string;
|
||||||
|
title?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
onActivate?: () => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
onSplit?: () => void;
|
||||||
|
canSplit?: boolean;
|
||||||
|
canClose?: boolean;
|
||||||
|
disableActivate?: boolean;
|
||||||
|
disableSplit?: boolean;
|
||||||
|
disableClose?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Tab({
|
||||||
|
tabType,
|
||||||
|
title,
|
||||||
|
isActive,
|
||||||
|
onActivate,
|
||||||
|
onClose,
|
||||||
|
onSplit,
|
||||||
|
canSplit = false,
|
||||||
|
canClose = false,
|
||||||
|
disableActivate = false,
|
||||||
|
disableSplit = false,
|
||||||
|
disableClose = false
|
||||||
|
}: TabProps): React.ReactElement {
|
||||||
|
if (tabType === "home") {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={`!px-2 border-1 border-[#303032] ${isActive ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30]' : ''}`}
|
||||||
|
onClick={onActivate}
|
||||||
|
disabled={disableActivate}
|
||||||
|
>
|
||||||
|
<Home/>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tabType === "terminal" || tabType === "server" || tabType === "file_manager") {
|
||||||
|
const isServer = tabType === 'server';
|
||||||
|
const isFileManager = tabType === 'file_manager';
|
||||||
|
return (
|
||||||
|
<ButtonGroup>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={`!px-2 border-1 border-[#303032] ${isActive ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30]' : ''}`}
|
||||||
|
onClick={onActivate}
|
||||||
|
disabled={disableActivate}
|
||||||
|
>
|
||||||
|
{isServer ? <ServerIcon className="mr-1 h-4 w-4"/> : isFileManager ?
|
||||||
|
<FolderIcon className="mr-1 h-4 w-4"/> : <TerminalIcon className="mr-1 h-4 w-4"/>}
|
||||||
|
{title || (isServer ? 'Server' : isFileManager ? 'file_manager' : 'Terminal')}
|
||||||
|
</Button>
|
||||||
|
{canSplit && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="!px-2 border-1 border-[#303032]"
|
||||||
|
onClick={onSplit}
|
||||||
|
disabled={disableSplit}
|
||||||
|
title={disableSplit ? 'Cannot split this tab' : 'Split'}
|
||||||
|
>
|
||||||
|
<SeparatorVertical className="w-[28px] h-[28px]"/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canClose && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="!px-2 border-1 border-[#303032]"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={disableClose}
|
||||||
|
>
|
||||||
|
<X/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</ButtonGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tabType === "ssh_manager") {
|
||||||
|
return (
|
||||||
|
<ButtonGroup>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={`!px-2 border-1 border-[#303032] ${isActive ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30]' : ''}`}
|
||||||
|
onClick={onActivate}
|
||||||
|
disabled={disableActivate}
|
||||||
|
>
|
||||||
|
{title || "SSH Manager"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="!px-2 border-1 border-[#303032]"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={disableClose}
|
||||||
|
>
|
||||||
|
<X/>
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tabType === "admin") {
|
||||||
|
return (
|
||||||
|
<ButtonGroup>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={`!px-2 border-1 border-[#303032] ${isActive ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30]' : ''}`}
|
||||||
|
onClick={onActivate}
|
||||||
|
disabled={disableActivate}
|
||||||
|
>
|
||||||
|
{title || "Admin"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="!px-2 border-1 border-[#303032]"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={disableClose}
|
||||||
|
>
|
||||||
|
<X/>
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
133
src/ui/Navigation/Tabs/TabContext.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import React, {createContext, useContext, useState, useRef, type ReactNode} from 'react';
|
||||||
|
|
||||||
|
export interface Tab {
|
||||||
|
id: number;
|
||||||
|
type: 'home' | 'terminal' | 'ssh_manager' | 'server' | 'admin' | 'file_manager';
|
||||||
|
title: string;
|
||||||
|
hostConfig?: any;
|
||||||
|
terminalRef?: React.RefObject<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabContextType {
|
||||||
|
tabs: Tab[];
|
||||||
|
currentTab: number | null;
|
||||||
|
allSplitScreenTab: number[];
|
||||||
|
addTab: (tab: Omit<Tab, 'id'>) => number;
|
||||||
|
removeTab: (tabId: number) => void;
|
||||||
|
setCurrentTab: (tabId: number) => void;
|
||||||
|
setSplitScreenTab: (tabId: number) => void;
|
||||||
|
getTab: (tabId: number) => Tab | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TabContext = createContext<TabContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function useTabs() {
|
||||||
|
const context = useContext(TabContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useTabs must be used within a TabProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabProvider({children}: TabProviderProps) {
|
||||||
|
const [tabs, setTabs] = useState<Tab[]>([
|
||||||
|
{id: 1, type: 'home', title: 'Home'}
|
||||||
|
]);
|
||||||
|
const [currentTab, setCurrentTab] = useState<number>(1);
|
||||||
|
const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]);
|
||||||
|
const nextTabId = useRef(2);
|
||||||
|
|
||||||
|
function computeUniqueTitle(tabType: Tab['type'], desiredTitle: string | undefined): string {
|
||||||
|
const defaultTitle = tabType === 'server' ? 'Server' : (tabType === 'file_manager' ? 'File Manager' : 'Terminal');
|
||||||
|
const baseTitle = (desiredTitle || defaultTitle).trim();
|
||||||
|
const match = baseTitle.match(/^(.*) \((\d+)\)$/);
|
||||||
|
const root = match ? match[1] : baseTitle;
|
||||||
|
|
||||||
|
const usedNumbers = new Set<number>();
|
||||||
|
let rootUsed = false;
|
||||||
|
tabs.forEach(t => {
|
||||||
|
if (!t.title) return;
|
||||||
|
if (t.title === root) {
|
||||||
|
rootUsed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const m = t.title.match(new RegExp(`^${root.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&")} \\((\\d+)\\)$`));
|
||||||
|
if (m) {
|
||||||
|
const n = parseInt(m[1], 10);
|
||||||
|
if (!isNaN(n)) usedNumbers.add(n);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rootUsed) return root;
|
||||||
|
let n = 2;
|
||||||
|
while (usedNumbers.has(n)) n += 1;
|
||||||
|
return `${root} (${n})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const addTab = (tabData: Omit<Tab, 'id'>): number => {
|
||||||
|
const id = nextTabId.current++;
|
||||||
|
const needsUniqueTitle = tabData.type === 'terminal' || tabData.type === 'server' || tabData.type === 'file_manager';
|
||||||
|
const effectiveTitle = needsUniqueTitle ? computeUniqueTitle(tabData.type, tabData.title) : (tabData.title || '');
|
||||||
|
const newTab: Tab = {
|
||||||
|
...tabData,
|
||||||
|
id,
|
||||||
|
title: effectiveTitle,
|
||||||
|
terminalRef: tabData.type === 'terminal' ? React.createRef<any>() : undefined
|
||||||
|
};
|
||||||
|
setTabs(prev => [...prev, newTab]);
|
||||||
|
setCurrentTab(id);
|
||||||
|
setAllSplitScreenTab(prev => prev.filter(tid => tid !== id));
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTab = (tabId: number) => {
|
||||||
|
const tab = tabs.find(t => t.id === tabId);
|
||||||
|
if (tab && tab.terminalRef?.current && typeof tab.terminalRef.current.disconnect === "function") {
|
||||||
|
tab.terminalRef.current.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
setTabs(prev => prev.filter(tab => tab.id !== tabId));
|
||||||
|
setAllSplitScreenTab(prev => prev.filter(id => id !== tabId));
|
||||||
|
|
||||||
|
if (currentTab === tabId) {
|
||||||
|
const remainingTabs = tabs.filter(tab => tab.id !== tabId);
|
||||||
|
setCurrentTab(remainingTabs.length > 0 ? remainingTabs[0].id : 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSplitScreenTab = (tabId: number) => {
|
||||||
|
setAllSplitScreenTab(prev => {
|
||||||
|
if (prev.includes(tabId)) {
|
||||||
|
return prev.filter(id => id !== tabId);
|
||||||
|
} else if (prev.length < 3) {
|
||||||
|
return [...prev, tabId];
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTab = (tabId: number) => {
|
||||||
|
return tabs.find(tab => tab.id === tabId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const value: TabContextType = {
|
||||||
|
tabs,
|
||||||
|
currentTab,
|
||||||
|
allSplitScreenTab,
|
||||||
|
addTab,
|
||||||
|
removeTab,
|
||||||
|
setCurrentTab,
|
||||||
|
setSplitScreenTab,
|
||||||
|
getTab,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</TabContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
456
src/ui/Navigation/TopNavbar.tsx
Normal file
@@ -0,0 +1,456 @@
|
|||||||
|
import React, {useState} from "react";
|
||||||
|
import {useSidebar} from "@/components/ui/sidebar";
|
||||||
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
|
import {ChevronDown, ChevronUpIcon, Hammer} from "lucide-react";
|
||||||
|
import {Tab} from "@/ui/Navigation/Tabs/Tab.tsx";
|
||||||
|
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion.tsx";
|
||||||
|
import {Input} from "@/components/ui/input.tsx";
|
||||||
|
import {Checkbox} from "@/components/ui/checkbox.tsx";
|
||||||
|
import {Separator} from "@/components/ui/separator.tsx";
|
||||||
|
|
||||||
|
interface TopNavbarProps {
|
||||||
|
isTopbarOpen: boolean;
|
||||||
|
setIsTopbarOpen: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): React.ReactElement {
|
||||||
|
const {state} = useSidebar();
|
||||||
|
const {tabs, currentTab, setCurrentTab, setSplitScreenTab, removeTab, allSplitScreenTab} = useTabs() as any;
|
||||||
|
const leftPosition = state === "collapsed" ? "26px" : "264px";
|
||||||
|
|
||||||
|
const [toolsSheetOpen, setToolsSheetOpen] = useState(false);
|
||||||
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
|
||||||
|
|
||||||
|
const handleTabActivate = (tabId: number) => {
|
||||||
|
setCurrentTab(tabId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTabSplit = (tabId: number) => {
|
||||||
|
setSplitScreenTab(tabId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTabClose = (tabId: number) => {
|
||||||
|
removeTab(tabId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTabToggle = (tabId: number) => {
|
||||||
|
setSelectedTabIds(prev => prev.includes(tabId) ? prev.filter(id => id !== tabId) : [...prev, tabId]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartRecording = () => {
|
||||||
|
setIsRecording(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
const input = document.getElementById('ssh-tools-input') as HTMLInputElement;
|
||||||
|
if (input) input.focus();
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStopRecording = () => {
|
||||||
|
setIsRecording(false);
|
||||||
|
setSelectedTabIds([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (selectedTabIds.length === 0) return;
|
||||||
|
|
||||||
|
const value = e.currentTarget.value;
|
||||||
|
let commandToSend = '';
|
||||||
|
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
if (e.key === 'c') {
|
||||||
|
commandToSend = '\x03'; // Ctrl+C (SIGINT)
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'd') {
|
||||||
|
commandToSend = '\x04'; // Ctrl+D (EOF)
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'l') {
|
||||||
|
commandToSend = '\x0c'; // Ctrl+L (clear screen)
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'u') {
|
||||||
|
commandToSend = '\x15'; // Ctrl+U (clear line)
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'k') {
|
||||||
|
commandToSend = '\x0b'; // Ctrl+K (clear from cursor to end)
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'a') {
|
||||||
|
commandToSend = '\x01'; // Ctrl+A (move to beginning of line)
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'e') {
|
||||||
|
commandToSend = '\x05'; // Ctrl+E (move to end of line)
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'w') {
|
||||||
|
commandToSend = '\x17'; // Ctrl+W (delete word before cursor)
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
commandToSend = '\n';
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'Backspace') {
|
||||||
|
commandToSend = '\x08'; // Backspace
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'Delete') {
|
||||||
|
commandToSend = '\x7f'; // Delete
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'Tab') {
|
||||||
|
commandToSend = '\x09'; // Tab
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
commandToSend = '\x1b'; // Escape
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
commandToSend = '\x1b[A'; // Up arrow
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'ArrowDown') {
|
||||||
|
commandToSend = '\x1b[B'; // Down arrow
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'ArrowLeft') {
|
||||||
|
commandToSend = '\x1b[D'; // Left arrow
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'ArrowRight') {
|
||||||
|
commandToSend = '\x1b[C'; // Right arrow
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'Home') {
|
||||||
|
commandToSend = '\x1b[H'; // Home
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'End') {
|
||||||
|
commandToSend = '\x1b[F'; // End
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'PageUp') {
|
||||||
|
commandToSend = '\x1b[5~'; // Page Up
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'PageDown') {
|
||||||
|
commandToSend = '\x1b[6~'; // Page Down
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'Insert') {
|
||||||
|
commandToSend = '\x1b[2~'; // Insert
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'F1') {
|
||||||
|
commandToSend = '\x1bOP'; // F1
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'F2') {
|
||||||
|
commandToSend = '\x1bOQ'; // F2
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'F3') {
|
||||||
|
commandToSend = '\x1bOR'; // F3
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'F4') {
|
||||||
|
commandToSend = '\x1bOS'; // F4
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'F5') {
|
||||||
|
commandToSend = '\x1b[15~'; // F5
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'F6') {
|
||||||
|
commandToSend = '\x1b[17~'; // F6
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'F7') {
|
||||||
|
commandToSend = '\x1b[18~'; // F7
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'F8') {
|
||||||
|
commandToSend = '\x1b[19~'; // F8
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'F9') {
|
||||||
|
commandToSend = '\x1b[20~'; // F9
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'F10') {
|
||||||
|
commandToSend = '\x1b[21~'; // F10
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'F11') {
|
||||||
|
commandToSend = '\x1b[23~'; // F11
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'F12') {
|
||||||
|
commandToSend = '\x1b[24~'; // F12
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commandToSend) {
|
||||||
|
selectedTabIds.forEach(tabId => {
|
||||||
|
const tab = tabs.find((t: any) => t.id === tabId);
|
||||||
|
if (tab?.terminalRef?.current?.sendInput) {
|
||||||
|
tab.terminalRef.current.sendInput(commandToSend);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (selectedTabIds.length === 0) return;
|
||||||
|
|
||||||
|
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
|
||||||
|
const char = e.key;
|
||||||
|
selectedTabIds.forEach(tabId => {
|
||||||
|
const tab = tabs.find((t: any) => t.id === tabId);
|
||||||
|
if (tab?.terminalRef?.current?.sendInput) {
|
||||||
|
tab.terminalRef.current.sendInput(char);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSplitScreenActive = Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0;
|
||||||
|
const currentTabObj = tabs.find((t: any) => t.id === currentTab);
|
||||||
|
const currentTabIsHome = currentTabObj?.type === 'home';
|
||||||
|
const currentTabIsSshManager = currentTabObj?.type === 'ssh_manager';
|
||||||
|
const currentTabIsAdmin = currentTabObj?.type === 'admin';
|
||||||
|
|
||||||
|
const terminalTabs = tabs.filter((tab: any) => tab.type === 'terminal');
|
||||||
|
|
||||||
|
function getCookie(name: string) {
|
||||||
|
return document.cookie.split('; ').reduce((r, v) => {
|
||||||
|
const parts = v.split('=');
|
||||||
|
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
|
||||||
|
}, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateRightClickCopyPaste = (checked: boolean) => {
|
||||||
|
document.cookie = `rightClickCopyPaste=${checked}; expires=2147483647; path=/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className="fixed z-10 h-[50px] bg-[#18181b] border-2 border-[#303032] rounded-lg transition-all duration-200 ease-linear flex flex-row"
|
||||||
|
style={{
|
||||||
|
top: isTopbarOpen ? "0.5rem" : "-3rem",
|
||||||
|
left: leftPosition,
|
||||||
|
right: "17px",
|
||||||
|
position: "fixed",
|
||||||
|
transform: "none",
|
||||||
|
margin: "0",
|
||||||
|
padding: "0"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full p-1 pr-2 border-r-2 border-[#303032] w-[calc(100%-6rem)] flex items-center overflow-x-auto overflow-y-hidden gap-2 thin-scrollbar">
|
||||||
|
{tabs.map((tab: any) => {
|
||||||
|
const isActive = tab.id === currentTab;
|
||||||
|
const isSplit = Array.isArray(allSplitScreenTab) && allSplitScreenTab.includes(tab.id);
|
||||||
|
const isTerminal = tab.type === 'terminal';
|
||||||
|
const isServer = tab.type === 'server';
|
||||||
|
const isFileManager = tab.type === 'file_manager';
|
||||||
|
const isSshManager = tab.type === 'ssh_manager';
|
||||||
|
const isAdmin = tab.type === 'admin';
|
||||||
|
const isSplittable = isTerminal || isServer || isFileManager;
|
||||||
|
const isSplitButtonDisabled = (isActive && !isSplitScreenActive) || ((allSplitScreenTab?.length || 0) >= 3 && !isSplit);
|
||||||
|
const disableSplit = !isSplittable || isSplitButtonDisabled || isActive || currentTabIsHome || currentTabIsSshManager || currentTabIsAdmin;
|
||||||
|
const disableActivate = isSplit || ((tab.type === 'home' || tab.type === 'ssh_manager' || tab.type === 'admin') && isSplitScreenActive);
|
||||||
|
const disableClose = (isSplitScreenActive && isActive) || isSplit;
|
||||||
|
return (
|
||||||
|
<Tab
|
||||||
|
key={tab.id}
|
||||||
|
tabType={tab.type}
|
||||||
|
title={tab.title}
|
||||||
|
isActive={isActive}
|
||||||
|
onActivate={() => handleTabActivate(tab.id)}
|
||||||
|
onClose={isTerminal || isServer || isFileManager || isSshManager || isAdmin ? () => handleTabClose(tab.id) : undefined}
|
||||||
|
onSplit={isSplittable ? () => handleTabSplit(tab.id) : undefined}
|
||||||
|
canSplit={isSplittable}
|
||||||
|
canClose={isTerminal || isServer || isFileManager || isSshManager || isAdmin}
|
||||||
|
disableActivate={disableActivate}
|
||||||
|
disableSplit={disableSplit}
|
||||||
|
disableClose={disableClose}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center gap-2 flex-1 px-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-[30px] h-[30px]"
|
||||||
|
title="SSH Tools"
|
||||||
|
onClick={() => setToolsSheetOpen(true)}
|
||||||
|
>
|
||||||
|
<Hammer className="h-4 w-4"/>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsTopbarOpen(false)}
|
||||||
|
className="w-[30px] h-[30px]"
|
||||||
|
>
|
||||||
|
<ChevronUpIcon/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isTopbarOpen && (
|
||||||
|
<div
|
||||||
|
onClick={() => setIsTopbarOpen(true)}
|
||||||
|
className="absolute top-0 left-0 w-full h-[10px] bg-[#18181b] cursor-pointer z-20 flex items-center justify-center rounded-bl-md rounded-br-md">
|
||||||
|
<ChevronDown size={10}/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{toolsSheetOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[999999] flex justify-end"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
zIndex: 999999,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
isolation: 'isolate',
|
||||||
|
transform: 'translateZ(0)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => setToolsSheetOpen(false)}
|
||||||
|
style={{cursor: 'pointer'}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="w-[400px] h-full bg-[#18181b] border-l-2 border-[#303032] flex flex-col shadow-2xl"
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#18181b',
|
||||||
|
boxShadow: '-4px 0 20px rgba(0, 0, 0, 0.5)',
|
||||||
|
zIndex: 999999,
|
||||||
|
position: 'relative',
|
||||||
|
isolation: 'isolate',
|
||||||
|
transform: 'translateZ(0)'
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-[#303032]">
|
||||||
|
<h2 className="text-lg font-semibold text-white">SSH Tools</h2>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setToolsSheetOpen(false)}
|
||||||
|
className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center"
|
||||||
|
title="Close SSH Tools"
|
||||||
|
>
|
||||||
|
<span className="text-lg font-bold leading-none">×</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h1 className="font-semibold">
|
||||||
|
Key Recording
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{!isRecording ? (
|
||||||
|
<Button
|
||||||
|
onClick={handleStartRecording}
|
||||||
|
className="flex-1"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
Start Key Recording
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={handleStopRecording}
|
||||||
|
className="flex-1"
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
Stop Key Recording
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isRecording && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-white">Select
|
||||||
|
terminals:</label>
|
||||||
|
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto mt-2">
|
||||||
|
{terminalTabs.map(tab => (
|
||||||
|
<Button
|
||||||
|
key={tab.id}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className={`rounded-full px-3 py-1 text-xs flex items-center gap-1 ${
|
||||||
|
selectedTabIds.includes(tab.id)
|
||||||
|
? 'text-white bg-gray-700'
|
||||||
|
: 'text-gray-500'
|
||||||
|
}`}
|
||||||
|
onClick={() => handleTabToggle(tab.id)}
|
||||||
|
>
|
||||||
|
{tab.title}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-white">Type commands (all
|
||||||
|
keys supported):</label>
|
||||||
|
<Input
|
||||||
|
id="ssh-tools-input"
|
||||||
|
placeholder="Type here"
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onKeyPress={handleKeyPress}
|
||||||
|
className="font-mono mt-2"
|
||||||
|
disabled={selectedTabIds.length === 0}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Commands will be sent to {selectedTabIds.length} selected
|
||||||
|
terminal(s).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="my-4"/>
|
||||||
|
|
||||||
|
<h1 className="font-semibold">
|
||||||
|
Settings
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="enable-copy-paste"
|
||||||
|
onCheckedChange={updateRightClickCopyPaste}
|
||||||
|
defaultChecked={getCookie("rightClickCopyPaste") === "true"}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="enable-copy-paste"
|
||||||
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-white"
|
||||||
|
>
|
||||||
|
Enable right‑click copy/paste
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="my-4"/>
|
||||||
|
|
||||||
|
<p className="pt-2 pb-2 text-sm text-gray-500">
|
||||||
|
Have ideas for what should come next for ssh tools? Share them on{" "}
|
||||||
|
<a
|
||||||
|
href="https://github.com/LukeGus/Termix/issues/new"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-500 hover:underline"
|
||||||
|
>
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
24
src/ui/apps/File Manager/FIleManagerTopNavbar.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { FileManagerTabList } from "./FileManagerTabList.tsx";
|
||||||
|
|
||||||
|
interface FileManagerTopNavbarProps {
|
||||||
|
tabs: {id: string | number, title: string}[];
|
||||||
|
activeTab: string | number;
|
||||||
|
setActiveTab: (tab: string | number) => void;
|
||||||
|
closeTab: (tab: string | number) => void;
|
||||||
|
onHomeClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FIleManagerTopNavbar(props: FileManagerTopNavbarProps): React.ReactElement {
|
||||||
|
const { tabs, activeTab, setActiveTab, closeTab, onHomeClick } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FileManagerTabList
|
||||||
|
tabs={tabs}
|
||||||
|
activeTab={activeTab}
|
||||||
|
setActiveTab={setActiveTab}
|
||||||
|
closeTab={closeTab}
|
||||||
|
onHomeClick={onHomeClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,26 +1,30 @@
|
|||||||
import React, {useState, useEffect, useRef} from "react";
|
import React, {useState, useEffect, useRef} from "react";
|
||||||
import {ConfigEditorSidebar} from "@/apps/SSH/Config Editor/ConfigEditorSidebar.tsx";
|
import {FileManagerLeftSidebar} from "@/ui/apps/File Manager/FileManagerLeftSidebar.tsx";
|
||||||
import {ConfigTabList} from "@/apps/SSH/Config Editor/ConfigTabList.tsx";
|
import {FileManagerTabList} from "@/ui/apps/File Manager/FileManagerTabList.tsx";
|
||||||
import {ConfigHomeView} from "@/apps/SSH/Config Editor/ConfigHomeView.tsx";
|
import {FileManagerHomeView} from "@/ui/apps/File Manager/FileManagerHomeView.tsx";
|
||||||
import {ConfigCodeEditor} from "@/apps/SSH/Config Editor/ConfigCodeEditor.tsx";
|
import {FileManagerFileEditor} from "@/ui/apps/File Manager/FileManagerFileEditor.tsx";
|
||||||
|
import {FileManagerOperations} from "@/ui/apps/File Manager/FileManagerOperations.tsx";
|
||||||
import {Button} from '@/components/ui/button.tsx';
|
import {Button} from '@/components/ui/button.tsx';
|
||||||
import {ConfigTopbar} from "@/apps/SSH/Config Editor/ConfigTopbar.tsx";
|
import {FIleManagerTopNavbar} from "@/ui/apps/File Manager/FIleManagerTopNavbar.tsx";
|
||||||
import {cn} from '@/lib/utils.ts';
|
import {cn} from '@/lib/utils.ts';
|
||||||
|
import {Save, RefreshCw, Settings, Trash2} from 'lucide-react';
|
||||||
|
import {Separator} from '@/components/ui/separator.tsx';
|
||||||
|
import {toast} from 'sonner';
|
||||||
import {
|
import {
|
||||||
getConfigEditorRecent,
|
getFileManagerRecent,
|
||||||
getConfigEditorPinned,
|
getFileManagerPinned,
|
||||||
getConfigEditorShortcuts,
|
getFileManagerShortcuts,
|
||||||
addConfigEditorRecent,
|
addFileManagerRecent,
|
||||||
removeConfigEditorRecent,
|
removeFileManagerRecent,
|
||||||
addConfigEditorPinned,
|
addFileManagerPinned,
|
||||||
removeConfigEditorPinned,
|
removeFileManagerPinned,
|
||||||
addConfigEditorShortcut,
|
addFileManagerShortcut,
|
||||||
removeConfigEditorShortcut,
|
removeFileManagerShortcut,
|
||||||
readSSHFile,
|
readSSHFile,
|
||||||
writeSSHFile,
|
writeSSHFile,
|
||||||
getSSHStatus,
|
getSSHStatus,
|
||||||
connectSSH
|
connectSSH
|
||||||
} from '@/apps/SSH/ssh-axios.ts';
|
} from '@/ui/main-axios.ts';
|
||||||
|
|
||||||
interface Tab {
|
interface Tab {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
@@ -31,8 +35,6 @@ interface Tab {
|
|||||||
sshSessionId?: string;
|
sshSessionId?: string;
|
||||||
filePath?: string;
|
filePath?: string;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
error?: string;
|
|
||||||
success?: string;
|
|
||||||
dirty?: boolean;
|
dirty?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,14 +54,18 @@ interface SSHHost {
|
|||||||
keyType?: string;
|
keyType?: string;
|
||||||
enableTerminal: boolean;
|
enableTerminal: boolean;
|
||||||
enableTunnel: boolean;
|
enableTunnel: boolean;
|
||||||
enableConfigEditor: boolean;
|
enableFileManager: boolean;
|
||||||
defaultPath: string;
|
defaultPath: string;
|
||||||
tunnelConnections: any[];
|
tunnelConnections: any[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => void }): React.ReactElement {
|
export function FileManager({onSelectView, embedded = false, initialHost = null}: {
|
||||||
|
onSelectView?: (view: string) => void,
|
||||||
|
embedded?: boolean,
|
||||||
|
initialHost?: SSHHost | null
|
||||||
|
}): React.ReactElement {
|
||||||
const [tabs, setTabs] = useState<Tab[]>([]);
|
const [tabs, setTabs] = useState<Tab[]>([]);
|
||||||
const [activeTab, setActiveTab] = useState<string | number>('home');
|
const [activeTab, setActiveTab] = useState<string | number>('home');
|
||||||
const [recent, setRecent] = useState<any[]>([]);
|
const [recent, setRecent] = useState<any[]>([]);
|
||||||
@@ -69,8 +75,28 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
|
|||||||
const [currentHost, setCurrentHost] = useState<SSHHost | null>(null);
|
const [currentHost, setCurrentHost] = useState<SSHHost | null>(null);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
const [showOperations, setShowOperations] = useState(false);
|
||||||
|
const [currentPath, setCurrentPath] = useState('/');
|
||||||
|
|
||||||
|
const [deletingItem, setDeletingItem] = useState<any | null>(null);
|
||||||
|
|
||||||
const sidebarRef = useRef<any>(null);
|
const sidebarRef = useRef<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialHost && (!currentHost || currentHost.id !== initialHost.id)) {
|
||||||
|
setCurrentHost(initialHost);
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
const path = initialHost.defaultPath || '/';
|
||||||
|
if (sidebarRef.current && sidebarRef.current.openFolder) {
|
||||||
|
sidebarRef.current.openFolder(initialHost, path);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}, [initialHost]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentHost) {
|
if (currentHost) {
|
||||||
fetchHomeData();
|
fetchHomeData();
|
||||||
@@ -102,16 +128,16 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const homeDataPromise = Promise.all([
|
const homeDataPromise = Promise.all([
|
||||||
getConfigEditorRecent(currentHost.id),
|
getFileManagerRecent(currentHost.id),
|
||||||
getConfigEditorPinned(currentHost.id),
|
getFileManagerPinned(currentHost.id),
|
||||||
getConfigEditorShortcuts(currentHost.id),
|
getFileManagerShortcuts(currentHost.id),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const timeoutPromise = new Promise((_, reject) =>
|
const timeoutPromise = new Promise((_, reject) =>
|
||||||
setTimeout(() => reject(new Error('Fetch home data timed out')), 15000)
|
setTimeout(() => reject(new Error('Fetch home data timed out')), 15000)
|
||||||
);
|
);
|
||||||
|
|
||||||
const [recentRes, pinnedRes, shortcutsRes] = await Promise.race([homeDataPromise, timeoutPromise]);
|
const [recentRes, pinnedRes, shortcutsRes] = await Promise.race([homeDataPromise, timeoutPromise]) as [any, any, any];
|
||||||
|
|
||||||
const recentWithPinnedStatus = (recentRes || []).map(file => ({
|
const recentWithPinnedStatus = (recentRes || []).map(file => ({
|
||||||
...file,
|
...file,
|
||||||
@@ -181,7 +207,7 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
|
|||||||
loading: false,
|
loading: false,
|
||||||
error: undefined
|
error: undefined
|
||||||
} : t));
|
} : t));
|
||||||
await addConfigEditorRecent({
|
await addFileManagerRecent({
|
||||||
name: file.name,
|
name: file.name,
|
||||||
path: file.path,
|
path: file.path,
|
||||||
isSSH: true,
|
isSSH: true,
|
||||||
@@ -191,7 +217,8 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
|
|||||||
fetchHomeData();
|
fetchHomeData();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage = formatErrorMessage(err, 'Cannot read file');
|
const errorMessage = formatErrorMessage(err, 'Cannot read file');
|
||||||
setTabs(tabs => tabs.map(t => t.id === tabId ? {...t, loading: false, error: errorMessage} : t));
|
toast.error(errorMessage);
|
||||||
|
setTabs(tabs => tabs.map(t => t.id === tabId ? {...t, loading: false} : t));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setActiveTab(tabId);
|
setActiveTab(tabId);
|
||||||
@@ -199,7 +226,7 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
|
|||||||
|
|
||||||
const handleRemoveRecent = async (file: any) => {
|
const handleRemoveRecent = async (file: any) => {
|
||||||
try {
|
try {
|
||||||
await removeConfigEditorRecent({
|
await removeFileManagerRecent({
|
||||||
name: file.name,
|
name: file.name,
|
||||||
path: file.path,
|
path: file.path,
|
||||||
isSSH: true,
|
isSSH: true,
|
||||||
@@ -213,7 +240,7 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
|
|||||||
|
|
||||||
const handlePinFile = async (file: any) => {
|
const handlePinFile = async (file: any) => {
|
||||||
try {
|
try {
|
||||||
await addConfigEditorPinned({
|
await addFileManagerPinned({
|
||||||
name: file.name,
|
name: file.name,
|
||||||
path: file.path,
|
path: file.path,
|
||||||
isSSH: true,
|
isSSH: true,
|
||||||
@@ -230,7 +257,7 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
|
|||||||
|
|
||||||
const handleUnpinFile = async (file: any) => {
|
const handleUnpinFile = async (file: any) => {
|
||||||
try {
|
try {
|
||||||
await removeConfigEditorPinned({
|
await removeFileManagerPinned({
|
||||||
name: file.name,
|
name: file.name,
|
||||||
path: file.path,
|
path: file.path,
|
||||||
isSSH: true,
|
isSSH: true,
|
||||||
@@ -270,7 +297,7 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
|
|||||||
const handleAddShortcut = async (folderPath: string) => {
|
const handleAddShortcut = async (folderPath: string) => {
|
||||||
try {
|
try {
|
||||||
const name = folderPath.split('/').pop() || folderPath;
|
const name = folderPath.split('/').pop() || folderPath;
|
||||||
await addConfigEditorShortcut({
|
await addFileManagerShortcut({
|
||||||
name,
|
name,
|
||||||
path: folderPath,
|
path: folderPath,
|
||||||
isSSH: true,
|
isSSH: true,
|
||||||
@@ -284,7 +311,7 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
|
|||||||
|
|
||||||
const handleRemoveShortcut = async (shortcut: any) => {
|
const handleRemoveShortcut = async (shortcut: any) => {
|
||||||
try {
|
try {
|
||||||
await removeConfigEditorShortcut({
|
await removeFileManagerShortcut({
|
||||||
name: shortcut.name,
|
name: shortcut.name,
|
||||||
path: shortcut.path,
|
path: shortcut.path,
|
||||||
isSSH: true,
|
isSSH: true,
|
||||||
@@ -345,7 +372,7 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
|
|||||||
setTimeout(() => reject(new Error('SSH status check timed out')), 10000)
|
setTimeout(() => reject(new Error('SSH status check timed out')), 10000)
|
||||||
);
|
);
|
||||||
|
|
||||||
const status = await Promise.race([statusPromise, statusTimeoutPromise]);
|
const status = await Promise.race([statusPromise, statusTimeoutPromise]) as { connected: boolean };
|
||||||
|
|
||||||
if (!status.connected) {
|
if (!status.connected) {
|
||||||
const connectPromise = connectSSH(tab.sshSessionId, {
|
const connectPromise = connectSSH(tab.sshSessionId, {
|
||||||
@@ -375,18 +402,15 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
|
|||||||
const result = await Promise.race([savePromise, timeoutPromise]);
|
const result = await Promise.race([savePromise, timeoutPromise]);
|
||||||
setTabs(tabs => tabs.map(t => t.id === tab.id ? {
|
setTabs(tabs => tabs.map(t => t.id === tab.id ? {
|
||||||
...t,
|
...t,
|
||||||
dirty: false,
|
loading: false
|
||||||
success: 'File saved successfully'
|
|
||||||
} : t));
|
} : t));
|
||||||
|
|
||||||
setTimeout(() => {
|
toast.success('File saved successfully');
|
||||||
setTabs(tabs => tabs.map(t => t.id === tab.id ? {...t, success: undefined} : t));
|
|
||||||
}, 3000);
|
|
||||||
|
|
||||||
Promise.allSettled([
|
Promise.allSettled([
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
await addConfigEditorRecent({
|
await addFileManagerRecent({
|
||||||
name: tab.fileName,
|
name: tab.fileName,
|
||||||
path: tab.filePath,
|
path: tab.filePath,
|
||||||
isSSH: true,
|
isSSH: true,
|
||||||
@@ -412,38 +436,73 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
|
|||||||
errorMessage = `Save operation timed out. The file may have been saved successfully, but the operation took too long to complete. Check the Docker logs for confirmation.`;
|
errorMessage = `Save operation timed out. The file may have been saved successfully, but the operation took too long to complete. Check the Docker logs for confirmation.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
setTabs(tabs => {
|
toast.error(`Failed to save file: ${errorMessage}`);
|
||||||
const updatedTabs = tabs.map(t => t.id === tab.id ? {
|
setTabs(tabs => tabs.map(t => t.id === tab.id ? {
|
||||||
...t,
|
...t,
|
||||||
error: `Failed to save file: ${errorMessage}`
|
loading: false
|
||||||
} : t);
|
} : t));
|
||||||
return updatedTabs;
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
setTabs(currentTabs => [...currentTabs]);
|
|
||||||
}, 100);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleHostChange = (host: SSHHost | null) => {
|
const handleHostChange = (_host: SSHHost | null) => {
|
||||||
setCurrentHost(host);
|
};
|
||||||
setTabs([]);
|
|
||||||
setActiveTab('home');
|
const handleOperationComplete = () => {
|
||||||
|
if (sidebarRef.current && sidebarRef.current.fetchFiles) {
|
||||||
|
sidebarRef.current.fetchFiles();
|
||||||
|
}
|
||||||
|
if (currentHost) {
|
||||||
|
fetchHomeData();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSuccess = (message: string) => {
|
||||||
|
toast.success(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleError = (error: string) => {
|
||||||
|
toast.error(error);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCurrentPath = (newPath: string) => {
|
||||||
|
setCurrentPath(newPath);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteFromSidebar = (item: any) => {
|
||||||
|
setDeletingItem(item);
|
||||||
|
};
|
||||||
|
|
||||||
|
const performDelete = async (item: any) => {
|
||||||
|
if (!currentHost?.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {deleteSSHItem} = await import('@/ui/main-axios.ts');
|
||||||
|
await deleteSSHItem(currentHost.id.toString(), item.path, item.type === 'directory');
|
||||||
|
toast.success(`${item.type === 'directory' ? 'Folder' : 'File'} deleted successfully`);
|
||||||
|
setDeletingItem(null);
|
||||||
|
handleOperationComplete();
|
||||||
|
} catch (error: any) {
|
||||||
|
handleError(error?.response?.data?.error || 'Failed to delete item');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!currentHost) {
|
if (!currentHost) {
|
||||||
return (
|
return (
|
||||||
<div style={{position: 'relative', width: '100vw', height: '100vh', overflow: 'hidden'}}>
|
<div style={{position: 'absolute', inset: 0, overflow: 'hidden'}} className="rounded-md">
|
||||||
<div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100vh', zIndex: 20}}>
|
<div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100%', zIndex: 20}}>
|
||||||
<ConfigEditorSidebar
|
<FileManagerLeftSidebar
|
||||||
onSelectView={onSelectView}
|
onSelectView={onSelectView || (() => {
|
||||||
|
})}
|
||||||
onOpenFile={handleOpenFile}
|
onOpenFile={handleOpenFile}
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
ref={sidebarRef}
|
ref={sidebarRef}
|
||||||
onHostChange={handleHostChange}
|
host={initialHost as SSHHost}
|
||||||
|
onOperationComplete={handleOperationComplete}
|
||||||
|
onError={handleError}
|
||||||
|
onSuccess={handleSuccess}
|
||||||
|
onPathChange={updateCurrentPath}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -467,25 +526,27 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{position: 'relative', width: '100vw', height: '100vh', overflow: 'hidden'}}>
|
<div style={{position: 'absolute', inset: 0, overflow: 'hidden'}} className="rounded-md">
|
||||||
<div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100vh', zIndex: 20}}>
|
<div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100%', zIndex: 20}}>
|
||||||
<ConfigEditorSidebar
|
<FileManagerLeftSidebar
|
||||||
onSelectView={onSelectView}
|
onSelectView={onSelectView || (() => {
|
||||||
|
})}
|
||||||
onOpenFile={handleOpenFile}
|
onOpenFile={handleOpenFile}
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
ref={sidebarRef}
|
ref={sidebarRef}
|
||||||
onHostChange={handleHostChange}
|
host={currentHost as SSHHost}
|
||||||
|
onOperationComplete={handleOperationComplete}
|
||||||
|
onError={handleError}
|
||||||
|
onSuccess={handleSuccess}
|
||||||
|
onPathChange={updateCurrentPath}
|
||||||
|
onDeleteItem={handleDeleteFromSidebar}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{position: 'absolute', top: 0, left: 256, right: 0, height: 44, zIndex: 30}}>
|
<div style={{position: 'absolute', top: 0, left: 256, right: 0, height: 50, zIndex: 30}}>
|
||||||
<div className="flex items-center w-full bg-[#18181b] border-b border-[#222224] h-11 relative px-4"
|
<div className="flex items-center w-full bg-[#18181b] border-b-2 border-[#303032] h-[50px] relative">
|
||||||
style={{height: 44}}>
|
|
||||||
{/* Tab list scrollable area */}
|
|
||||||
<div className="flex-1 min-w-0 h-full flex items-center">
|
|
||||||
<div
|
<div
|
||||||
className="h-9 w-full bg-[#09090b] border border-[#23232a] rounded-md flex items-center overflow-x-auto scrollbar-thin scrollbar-thumb-muted-foreground/30 scrollbar-track-transparent"
|
className="h-full p-1 pr-2 border-r-2 border-[#303032] w-[calc(100%-6rem)] flex items-center overflow-x-auto overflow-y-hidden gap-2 thin-scrollbar">
|
||||||
style={{minWidth: 0}}>
|
<FIleManagerTopNavbar
|
||||||
<ConfigTopbar
|
|
||||||
tabs={tabs.map(t => ({id: t.id, title: t.title}))}
|
tabs={tabs.map(t => ({id: t.id, title: t.title}))}
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
setActiveTab={setActiveTab}
|
setActiveTab={setActiveTab}
|
||||||
@@ -498,26 +559,36 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex items-center justify-center gap-2 flex-1">
|
||||||
{/* Save button - always visible */}
|
|
||||||
<Button
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowOperations(!showOperations)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'ml-4 px-4 py-1.5 border rounded-md text-sm font-medium transition-colors',
|
'w-[30px] h-[30px]',
|
||||||
'border-[#2d2d30] text-white bg-transparent hover:bg-[#23232a] active:bg-[#23232a] focus:bg-[#23232a]',
|
showOperations ? 'bg-[#2d2d30] border-[#434345]' : ''
|
||||||
activeTab === 'home' || !tabs.find(t => t.id === activeTab)?.dirty || isSaving ? 'opacity-60 cursor-not-allowed' : 'hover:border-[#2d2d30]'
|
|
||||||
)}
|
)}
|
||||||
disabled={activeTab === 'home' || !tabs.find(t => t.id === activeTab)?.dirty || isSaving}
|
title="File Operations"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4"/>
|
||||||
|
</Button>
|
||||||
|
<div className="p-0.25 w-px h-[30px] bg-[#303032]"></div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const tab = tabs.find(t => t.id === activeTab);
|
const tab = tabs.find(t => t.id === activeTab);
|
||||||
if (tab && !isSaving) handleSave(tab);
|
if (tab && !isSaving) handleSave(tab);
|
||||||
}}
|
}}
|
||||||
type="button"
|
disabled={activeTab === 'home' || !tabs.find(t => t.id === activeTab)?.dirty || isSaving}
|
||||||
style={{height: 36, alignSelf: 'center'}}
|
className={cn(
|
||||||
|
'w-[30px] h-[30px]',
|
||||||
|
activeTab === 'home' || !tabs.find(t => t.id === activeTab)?.dirty || isSaving ? 'opacity-60 cursor-not-allowed' : ''
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{isSaving ? 'Saving...' : 'Save'}
|
{isSaving ? <RefreshCw className="h-4 w-4 animate-spin"/> : <Save className="h-4 w-4"/>}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 44,
|
top: 44,
|
||||||
@@ -530,8 +601,10 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column'
|
flexDirection: 'column'
|
||||||
}}>
|
}}>
|
||||||
|
<div className="flex h-full">
|
||||||
|
<div className="flex-1">
|
||||||
{activeTab === 'home' ? (
|
{activeTab === 'home' ? (
|
||||||
<ConfigHomeView
|
<FileManagerHomeView
|
||||||
recent={recent}
|
recent={recent}
|
||||||
pinned={pinned}
|
pinned={pinned}
|
||||||
shortcuts={shortcuts}
|
shortcuts={shortcuts}
|
||||||
@@ -549,50 +622,8 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
|
|||||||
if (!tab) return null;
|
if (!tab) return null;
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full" style={{flex: 1, minHeight: 0}}>
|
<div className="flex flex-col h-full" style={{flex: 1, minHeight: 0}}>
|
||||||
{/* Error display */}
|
|
||||||
{tab.error && (
|
|
||||||
<div
|
|
||||||
className="bg-red-900/20 border border-red-500/30 text-red-300 px-4 py-3 text-sm">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-red-400">⚠️</span>
|
|
||||||
<span>{tab.error}</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setTabs(tabs => tabs.map(t => t.id === tab.id ? {
|
|
||||||
...t,
|
|
||||||
error: undefined
|
|
||||||
} : t))}
|
|
||||||
className="text-red-400 hover:text-red-300 transition-colors"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* Success display */}
|
|
||||||
{tab.success && (
|
|
||||||
<div
|
|
||||||
className="bg-green-900/20 border border-green-500/30 text-green-300 px-4 py-3 text-sm">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-green-400">✓</span>
|
|
||||||
<span>{tab.success}</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setTabs(tabs => tabs.map(t => t.id === tab.id ? {
|
|
||||||
...t,
|
|
||||||
success: undefined
|
|
||||||
} : t))}
|
|
||||||
className="text-green-400 hover:text-green-300 transition-colors"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0">
|
||||||
<ConfigCodeEditor
|
<FileManagerFileEditor
|
||||||
content={tab.content}
|
content={tab.content}
|
||||||
fileName={tab.fileName}
|
fileName={tab.fileName}
|
||||||
onContentChange={content => setTabContent(tab.id, content)}
|
onContentChange={content => setTabContent(tab.id, content)}
|
||||||
@@ -603,6 +634,57 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
|
|||||||
})()
|
})()
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{showOperations && (
|
||||||
|
<div className="w-80 border-l-2 border-[#303032] bg-[#09090b] overflow-y-auto">
|
||||||
|
<FileManagerOperations
|
||||||
|
currentPath={currentPath}
|
||||||
|
sshSessionId={currentHost?.id.toString() || null}
|
||||||
|
onOperationComplete={handleOperationComplete}
|
||||||
|
onError={handleError}
|
||||||
|
onSuccess={handleSuccess}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{deletingItem && (
|
||||||
|
<div className="fixed inset-0 z-[99999]">
|
||||||
|
<div className="absolute inset-0 bg-black/60"></div>
|
||||||
|
|
||||||
|
<div className="relative h-full flex items-center justify-center">
|
||||||
|
<div className="bg-[#18181b] border-2 border-[#303032] rounded-lg p-6 max-w-md mx-4 shadow-2xl">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
|
<Trash2 className="w-5 h-5 text-red-400"/>
|
||||||
|
Confirm Delete
|
||||||
|
</h3>
|
||||||
|
<p className="text-white mb-4">
|
||||||
|
Are you sure you want to delete <strong>{deletingItem.name}</strong>?
|
||||||
|
{deletingItem.type === 'directory' && ' This will delete the folder and all its contents.'}
|
||||||
|
</p>
|
||||||
|
<p className="text-red-400 text-sm mb-6">
|
||||||
|
This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => performDelete(deletingItem)}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDeletingItem(null)}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -5,13 +5,13 @@ import {hyperLink} from '@uiw/codemirror-extensions-hyper-link';
|
|||||||
import {oneDark} from '@codemirror/theme-one-dark';
|
import {oneDark} from '@codemirror/theme-one-dark';
|
||||||
import {EditorView} from '@codemirror/view';
|
import {EditorView} from '@codemirror/view';
|
||||||
|
|
||||||
interface ConfigCodeEditorProps {
|
interface FileManagerCodeEditorProps {
|
||||||
content: string;
|
content: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
onContentChange: (value: string) => void;
|
onContentChange: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConfigCodeEditor({content, fileName, onContentChange}: ConfigCodeEditorProps) {
|
export function FileManagerFileEditor({content, fileName, onContentChange}: FileManagerCodeEditorProps) {
|
||||||
function getLanguageName(filename: string): string {
|
function getLanguageName(filename: string): string {
|
||||||
if (!filename || typeof filename !== 'string') {
|
if (!filename || typeof filename !== 'string') {
|
||||||
return 'text';
|
return 'text';
|
||||||
@@ -18,7 +18,7 @@ interface ShortcutItem {
|
|||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ConfigHomeViewProps {
|
interface FileManagerHomeViewProps {
|
||||||
recent: FileItem[];
|
recent: FileItem[];
|
||||||
pinned: FileItem[];
|
pinned: FileItem[];
|
||||||
shortcuts: ShortcutItem[];
|
shortcuts: ShortcutItem[];
|
||||||
@@ -31,7 +31,7 @@ interface ConfigHomeViewProps {
|
|||||||
onAddShortcut: (path: string) => void;
|
onAddShortcut: (path: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConfigHomeView({
|
export function FileManagerHomeView({
|
||||||
recent,
|
recent,
|
||||||
pinned,
|
pinned,
|
||||||
shortcuts,
|
shortcuts,
|
||||||
@@ -42,14 +42,14 @@ export function ConfigHomeView({
|
|||||||
onOpenShortcut,
|
onOpenShortcut,
|
||||||
onRemoveShortcut,
|
onRemoveShortcut,
|
||||||
onAddShortcut
|
onAddShortcut
|
||||||
}: ConfigHomeViewProps) {
|
}: FileManagerHomeViewProps) {
|
||||||
const [tab, setTab] = useState<'recent' | 'pinned' | 'shortcuts'>('recent');
|
const [tab, setTab] = useState<'recent' | 'pinned' | 'shortcuts'>('recent');
|
||||||
const [newShortcut, setNewShortcut] = useState('');
|
const [newShortcut, setNewShortcut] = useState('');
|
||||||
|
|
||||||
|
|
||||||
const renderFileCard = (file: FileItem, onRemove: () => void, onPin?: () => void, isPinned = false) => (
|
const renderFileCard = (file: FileItem, onRemove: () => void, onPin?: () => void, isPinned = false) => (
|
||||||
<div key={file.path}
|
<div key={file.path}
|
||||||
className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border border-[#23232a] rounded hover:border-[#434345] transition-colors">
|
className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border-2 border-[#303032] rounded hover:border-[#434345] transition-colors">
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
|
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
|
||||||
onClick={() => onOpenFile(file)}
|
onClick={() => onOpenFile(file)}
|
||||||
@@ -92,7 +92,7 @@ export function ConfigHomeView({
|
|||||||
|
|
||||||
const renderShortcutCard = (shortcut: ShortcutItem) => (
|
const renderShortcutCard = (shortcut: ShortcutItem) => (
|
||||||
<div key={shortcut.path}
|
<div key={shortcut.path}
|
||||||
className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border border-[#23232a] rounded hover:border-[#434345] transition-colors">
|
className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border-2 border-[#303032] rounded hover:border-[#434345] transition-colors">
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
|
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
|
||||||
onClick={() => onOpenShortcut(shortcut)}
|
onClick={() => onOpenShortcut(shortcut)}
|
||||||
@@ -120,7 +120,7 @@ export function ConfigHomeView({
|
|||||||
return (
|
return (
|
||||||
<div className="p-4 flex flex-col gap-4 h-full bg-[#09090b]">
|
<div className="p-4 flex flex-col gap-4 h-full bg-[#09090b]">
|
||||||
<Tabs value={tab} onValueChange={v => setTab(v as 'recent' | 'pinned' | 'shortcuts')} className="w-full">
|
<Tabs value={tab} onValueChange={v => setTab(v as 'recent' | 'pinned' | 'shortcuts')} className="w-full">
|
||||||
<TabsList className="mb-4 bg-[#18181b] border border-[#23232a]">
|
<TabsList className="mb-4 bg-[#18181b] border-2 border-[#303032]">
|
||||||
<TabsTrigger value="recent" className="data-[state=active]:bg-[#23232a]">Recent</TabsTrigger>
|
<TabsTrigger value="recent" className="data-[state=active]:bg-[#23232a]">Recent</TabsTrigger>
|
||||||
<TabsTrigger value="pinned" className="data-[state=active]:bg-[#23232a]">Pinned</TabsTrigger>
|
<TabsTrigger value="pinned" className="data-[state=active]:bg-[#23232a]">Pinned</TabsTrigger>
|
||||||
<TabsTrigger value="shortcuts" className="data-[state=active]:bg-[#23232a]">Folder
|
<TabsTrigger value="shortcuts" className="data-[state=active]:bg-[#23232a]">Folder
|
||||||
@@ -128,7 +128,8 @@ export function ConfigHomeView({
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="recent" className="mt-0">
|
<TabsContent value="recent" className="mt-0">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
<div
|
||||||
|
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||||
{recent.length === 0 ? (
|
{recent.length === 0 ? (
|
||||||
<div className="flex items-center justify-center py-8 col-span-full">
|
<div className="flex items-center justify-center py-8 col-span-full">
|
||||||
<span className="text-sm text-muted-foreground">No recent files.</span>
|
<span className="text-sm text-muted-foreground">No recent files.</span>
|
||||||
@@ -145,7 +146,8 @@ export function ConfigHomeView({
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="pinned" className="mt-0">
|
<TabsContent value="pinned" className="mt-0">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
<div
|
||||||
|
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||||
{pinned.length === 0 ? (
|
{pinned.length === 0 ? (
|
||||||
<div className="flex items-center justify-center py-8 col-span-full">
|
<div className="flex items-center justify-center py-8 col-span-full">
|
||||||
<span className="text-sm text-muted-foreground">No pinned files.</span>
|
<span className="text-sm text-muted-foreground">No pinned files.</span>
|
||||||
@@ -162,12 +164,12 @@ export function ConfigHomeView({
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="shortcuts" className="mt-0">
|
<TabsContent value="shortcuts" className="mt-0">
|
||||||
<div className="flex items-center gap-3 mb-4 p-3 bg-[#18181b] border border-[#23232a] rounded-lg">
|
<div className="flex items-center gap-3 mb-4 p-3 bg-[#18181b] border-2 border-[#303032] rounded-lg">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Enter folder path"
|
placeholder="Enter folder path"
|
||||||
value={newShortcut}
|
value={newShortcut}
|
||||||
onChange={e => setNewShortcut(e.target.value)}
|
onChange={e => setNewShortcut(e.target.value)}
|
||||||
className="flex-1 bg-[#23232a] border-[#434345] text-white placeholder:text-muted-foreground"
|
className="flex-1 bg-[#23232a] border-2 border-[#303032] text-white placeholder:text-muted-foreground"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' && newShortcut.trim()) {
|
if (e.key === 'Enter' && newShortcut.trim()) {
|
||||||
onAddShortcut(newShortcut.trim());
|
onAddShortcut(newShortcut.trim());
|
||||||
@@ -177,8 +179,8 @@ export function ConfigHomeView({
|
|||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
className="h-8 px-2 bg-[#23232a] border-[#434345] hover:bg-[#2d2d30] rounded-md"
|
className="h-8 px-2 bg-[#23232a] border-2 !border-[#303032] hover:bg-[#2d2d30] rounded-md"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (newShortcut.trim()) {
|
if (newShortcut.trim()) {
|
||||||
onAddShortcut(newShortcut.trim());
|
onAddShortcut(newShortcut.trim());
|
||||||
@@ -190,7 +192,8 @@ export function ConfigHomeView({
|
|||||||
Add
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
<div
|
||||||
|
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||||
{shortcuts.length === 0 ? (
|
{shortcuts.length === 0 ? (
|
||||||
<div className="flex items-center justify-center py-4 col-span-full">
|
<div className="flex items-center justify-center py-4 col-span-full">
|
||||||
<span className="text-sm text-muted-foreground">No shortcuts.</span>
|
<span className="text-sm text-muted-foreground">No shortcuts.</span>
|
||||||
572
src/ui/apps/File Manager/FileManagerLeftSidebar.tsx
Normal file
@@ -0,0 +1,572 @@
|
|||||||
|
import React, {useEffect, useState, useRef, forwardRef, useImperativeHandle} from 'react';
|
||||||
|
import {Separator} from '@/components/ui/separator.tsx';
|
||||||
|
import {CornerDownLeft, Folder, File, Server, ArrowUp, Pin, MoreVertical, Trash2, Edit3} from 'lucide-react';
|
||||||
|
import {ScrollArea} from '@/components/ui/scroll-area.tsx';
|
||||||
|
import {cn} from '@/lib/utils.ts';
|
||||||
|
import {Input} from '@/components/ui/input.tsx';
|
||||||
|
import {Button} from '@/components/ui/button.tsx';
|
||||||
|
import {toast} from 'sonner';
|
||||||
|
import {
|
||||||
|
listSSHFiles,
|
||||||
|
renameSSHItem,
|
||||||
|
deleteSSHItem,
|
||||||
|
getFileManagerRecent,
|
||||||
|
getFileManagerPinned,
|
||||||
|
addFileManagerPinned,
|
||||||
|
removeFileManagerPinned,
|
||||||
|
readSSHFile,
|
||||||
|
getSSHStatus,
|
||||||
|
connectSSH
|
||||||
|
} from '@/ui/main-axios.ts';
|
||||||
|
|
||||||
|
interface SSHHost {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
folder: string;
|
||||||
|
tags: string[];
|
||||||
|
pin: boolean;
|
||||||
|
authType: string;
|
||||||
|
password?: string;
|
||||||
|
key?: string;
|
||||||
|
keyPassword?: string;
|
||||||
|
keyType?: string;
|
||||||
|
enableTerminal: boolean;
|
||||||
|
enableTunnel: boolean;
|
||||||
|
enableFileManager: boolean;
|
||||||
|
defaultPath: string;
|
||||||
|
tunnelConnections: any[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||||
|
{onSelectView, onOpenFile, tabs, host, onOperationComplete, onError, onSuccess, onPathChange, onDeleteItem}: {
|
||||||
|
onSelectView?: (view: string) => void;
|
||||||
|
onOpenFile: (file: any) => void;
|
||||||
|
tabs: any[];
|
||||||
|
host: SSHHost;
|
||||||
|
onOperationComplete?: () => void;
|
||||||
|
onError?: (error: string) => void;
|
||||||
|
onSuccess?: (message: string) => void;
|
||||||
|
onPathChange?: (path: string) => void;
|
||||||
|
onDeleteItem?: (item: any) => void;
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
const [currentPath, setCurrentPath] = useState('/');
|
||||||
|
const [files, setFiles] = useState<any[]>([]);
|
||||||
|
const pathInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||||
|
const [fileSearch, setFileSearch] = useState('');
|
||||||
|
const [debouncedFileSearch, setDebouncedFileSearch] = useState('');
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = setTimeout(() => setDebouncedSearch(search), 200);
|
||||||
|
return () => clearTimeout(handler);
|
||||||
|
}, [search]);
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = setTimeout(() => setDebouncedSearch(fileSearch), 200);
|
||||||
|
return () => clearTimeout(handler);
|
||||||
|
}, [fileSearch]);
|
||||||
|
|
||||||
|
const [sshSessionId, setSshSessionId] = useState<string | null>(null);
|
||||||
|
const [filesLoading, setFilesLoading] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [connectingSSH, setConnectingSSH] = useState(false);
|
||||||
|
const [connectionCache, setConnectionCache] = useState<Record<string, {
|
||||||
|
sessionId: string;
|
||||||
|
timestamp: number
|
||||||
|
}>>({});
|
||||||
|
const [fetchingFiles, setFetchingFiles] = useState(false);
|
||||||
|
|
||||||
|
const [contextMenu, setContextMenu] = useState<{
|
||||||
|
visible: boolean;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
item: any;
|
||||||
|
}>({
|
||||||
|
visible: false,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
item: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const [renamingItem, setRenamingItem] = useState<{
|
||||||
|
item: any;
|
||||||
|
newName: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nextPath = host?.defaultPath || '/';
|
||||||
|
setCurrentPath(nextPath);
|
||||||
|
onPathChange?.(nextPath);
|
||||||
|
(async () => {
|
||||||
|
await connectToSSH(host);
|
||||||
|
})();
|
||||||
|
}, [host?.id]);
|
||||||
|
|
||||||
|
async function connectToSSH(server: SSHHost): Promise<string | null> {
|
||||||
|
const sessionId = server.id.toString();
|
||||||
|
|
||||||
|
const cached = connectionCache[sessionId];
|
||||||
|
if (cached && Date.now() - cached.timestamp < 30000) {
|
||||||
|
setSshSessionId(cached.sessionId);
|
||||||
|
return cached.sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connectingSSH) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setConnectingSSH(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!server.password && !server.key) {
|
||||||
|
toast.error('No authentication credentials available for this SSH host');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectionConfig = {
|
||||||
|
ip: server.ip,
|
||||||
|
port: server.port,
|
||||||
|
username: server.username,
|
||||||
|
password: server.password,
|
||||||
|
sshKey: server.key,
|
||||||
|
keyPassword: server.keyPassword,
|
||||||
|
};
|
||||||
|
|
||||||
|
await connectSSH(sessionId, connectionConfig);
|
||||||
|
|
||||||
|
setSshSessionId(sessionId);
|
||||||
|
|
||||||
|
setConnectionCache(prev => ({
|
||||||
|
...prev,
|
||||||
|
[sessionId]: {sessionId, timestamp: Date.now()}
|
||||||
|
}));
|
||||||
|
|
||||||
|
return sessionId;
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err?.response?.data?.error || 'Failed to connect to SSH');
|
||||||
|
setSshSessionId(null);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setConnectingSSH(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFiles() {
|
||||||
|
if (fetchingFiles) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFetchingFiles(true);
|
||||||
|
setFiles([]);
|
||||||
|
setFilesLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let pinnedFiles: any[] = [];
|
||||||
|
try {
|
||||||
|
if (host) {
|
||||||
|
pinnedFiles = await getFileManagerPinned(host.id);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
}
|
||||||
|
|
||||||
|
if (host && sshSessionId) {
|
||||||
|
let res: any[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const status = await getSSHStatus(sshSessionId);
|
||||||
|
if (!status.connected) {
|
||||||
|
const newSessionId = await connectToSSH(host);
|
||||||
|
if (newSessionId) {
|
||||||
|
setSshSessionId(newSessionId);
|
||||||
|
res = await listSSHFiles(newSessionId, currentPath);
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to reconnect SSH session');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
res = await listSSHFiles(sshSessionId, currentPath);
|
||||||
|
}
|
||||||
|
} catch (sessionErr) {
|
||||||
|
const newSessionId = await connectToSSH(host);
|
||||||
|
if (newSessionId) {
|
||||||
|
setSshSessionId(newSessionId);
|
||||||
|
res = await listSSHFiles(newSessionId, currentPath);
|
||||||
|
} else {
|
||||||
|
throw sessionErr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const processedFiles = (res || []).map((f: any) => {
|
||||||
|
const filePath = currentPath + (currentPath.endsWith('/') ? '' : '/') + f.name;
|
||||||
|
const isPinned = pinnedFiles.some(pinned => pinned.path === filePath);
|
||||||
|
return {
|
||||||
|
...f,
|
||||||
|
path: filePath,
|
||||||
|
isPinned,
|
||||||
|
isSSH: true,
|
||||||
|
sshSessionId: sshSessionId
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setFiles(processedFiles);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setFiles([]);
|
||||||
|
toast.error(err?.response?.data?.error || err?.message || 'Failed to list files');
|
||||||
|
} finally {
|
||||||
|
setFilesLoading(false);
|
||||||
|
setFetchingFiles(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (host && sshSessionId && !connectingSSH && !fetchingFiles) {
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
fetchFiles();
|
||||||
|
}, 100);
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}, [currentPath, host, sshSessionId]);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
openFolder: async (_server: SSHHost, path: string) => {
|
||||||
|
if (connectingSSH || fetchingFiles) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPath === path) {
|
||||||
|
setTimeout(() => fetchFiles(), 100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFetchingFiles(false);
|
||||||
|
setFilesLoading(false);
|
||||||
|
setFiles([]);
|
||||||
|
|
||||||
|
setCurrentPath(path);
|
||||||
|
onPathChange?.(path);
|
||||||
|
if (!sshSessionId) {
|
||||||
|
const sessionId = await connectToSSH(host);
|
||||||
|
if (sessionId) setSshSessionId(sessionId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fetchFiles: () => {
|
||||||
|
if (host && sshSessionId) {
|
||||||
|
fetchFiles();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getCurrentPath: () => currentPath
|
||||||
|
}));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pathInputRef.current) {
|
||||||
|
pathInputRef.current.scrollLeft = pathInputRef.current.scrollWidth;
|
||||||
|
}
|
||||||
|
}, [currentPath]);
|
||||||
|
|
||||||
|
const filteredFiles = files.filter(file => {
|
||||||
|
const q = debouncedFileSearch.trim().toLowerCase();
|
||||||
|
if (!q) return true;
|
||||||
|
return file.name.toLowerCase().includes(q);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleContextMenu = (e: React.MouseEvent, item: any) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
|
||||||
|
const menuWidth = 160;
|
||||||
|
const menuHeight = 80;
|
||||||
|
|
||||||
|
let x = e.clientX;
|
||||||
|
let y = e.clientY;
|
||||||
|
|
||||||
|
if (x + menuWidth > viewportWidth) {
|
||||||
|
x = e.clientX - menuWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (y + menuHeight > viewportHeight) {
|
||||||
|
y = e.clientY - menuHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (x < 0) {
|
||||||
|
x = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (y < 0) {
|
||||||
|
y = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
setContextMenu({
|
||||||
|
visible: true,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
item
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeContextMenu = () => {
|
||||||
|
setContextMenu({ visible: false, x: 0, y: 0, item: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRename = async (item: any, newName: string) => {
|
||||||
|
if (!sshSessionId || !newName.trim() || newName === item.name) {
|
||||||
|
setRenamingItem(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await renameSSHItem(sshSessionId, item.path, newName.trim());
|
||||||
|
toast.success(`${item.type === 'directory' ? 'Folder' : 'File'} renamed successfully`);
|
||||||
|
setRenamingItem(null);
|
||||||
|
if (onOperationComplete) {
|
||||||
|
onOperationComplete();
|
||||||
|
} else {
|
||||||
|
fetchFiles();
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error?.response?.data?.error || 'Failed to rename item');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (item: any) => {
|
||||||
|
if (!sshSessionId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteSSHItem(sshSessionId, item.path, item.type === 'directory');
|
||||||
|
toast.success(`${item.type === 'directory' ? 'Folder' : 'File'} deleted successfully`);
|
||||||
|
if (onOperationComplete) {
|
||||||
|
onOperationComplete();
|
||||||
|
} else {
|
||||||
|
fetchFiles();
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error?.response?.data?.error || 'Failed to delete item');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startRename = (item: any) => {
|
||||||
|
setRenamingItem({ item, newName: item.name });
|
||||||
|
closeContextMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
const startDelete = (item: any) => {
|
||||||
|
onDeleteItem?.(item);
|
||||||
|
closeContextMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = () => closeContextMenu();
|
||||||
|
document.addEventListener('click', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('click', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePathChange = (newPath: string) => {
|
||||||
|
setCurrentPath(newPath);
|
||||||
|
onPathChange?.(newPath);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full w-[256px]" style={{maxWidth: 256}}>
|
||||||
|
<div className="flex flex-col flex-grow min-h-0">
|
||||||
|
<div className="flex-1 w-full h-full flex flex-col bg-[#09090b] border-r-2 border-[#303032] overflow-hidden p-0 relative min-h-0">
|
||||||
|
{host && (
|
||||||
|
<div className="flex flex-col h-full w-full" style={{maxWidth: 260}}>
|
||||||
|
<div className="flex items-center gap-2 px-2 py-1.5 border-b-2 border-[#303032] bg-[#18181b] z-20" style={{maxWidth: 260}}>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
className="h-9 w-9 bg-[#18181b] border-2 border-[#303032] rounded-md hover:bg-[#2d2d30] focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
onClick={() => {
|
||||||
|
let path = currentPath;
|
||||||
|
if (path && path !== '/' && path !== '') {
|
||||||
|
if (path.endsWith('/')) path = path.slice(0, -1);
|
||||||
|
const lastSlash = path.lastIndexOf('/');
|
||||||
|
if (lastSlash > 0) {
|
||||||
|
handlePathChange(path.slice(0, lastSlash));
|
||||||
|
} else {
|
||||||
|
handlePathChange('/');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
handlePathChange('/');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowUp className="w-4 h-4"/>
|
||||||
|
</Button>
|
||||||
|
<Input ref={pathInputRef} value={currentPath}
|
||||||
|
onChange={e => handlePathChange(e.target.value)}
|
||||||
|
className="flex-1 bg-[#18181b] border-2 border-[#434345] text-white truncate rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring hover:border-[#5a5a5d]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="px-2 py-2 border-b-1 border-[#303032] bg-[#18181b]">
|
||||||
|
<Input
|
||||||
|
placeholder="Search files and folders..."
|
||||||
|
className="w-full h-7 text-sm bg-[#23232a] border-2 border-[#434345] text-white placeholder:text-muted-foreground rounded-md"
|
||||||
|
autoComplete="off"
|
||||||
|
value={fileSearch}
|
||||||
|
onChange={e => setFileSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 w-full bg-[#09090b] border-t-1 border-[#303032]">
|
||||||
|
<ScrollArea className="h-full w-full bg-[#09090b]">
|
||||||
|
<div className="p-2 pb-0">
|
||||||
|
{connectingSSH || filesLoading ? (
|
||||||
|
<div className="text-xs text-muted-foreground">Loading...</div>
|
||||||
|
) : filteredFiles.length === 0 ? (
|
||||||
|
<div className="text-xs text-muted-foreground">No files or folders found.</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{filteredFiles.map((item: any) => {
|
||||||
|
const isOpen = (tabs || []).some((t: any) => t.id === item.path);
|
||||||
|
const isRenaming = renamingItem?.item?.path === item.path;
|
||||||
|
const isDeleting = false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.path}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-3 py-2 bg-[#18181b] border-2 border-[#303032] rounded group max-w-full relative",
|
||||||
|
isOpen && "opacity-60 cursor-not-allowed pointer-events-none"
|
||||||
|
)}
|
||||||
|
style={{maxWidth: 220, marginBottom: 8}}
|
||||||
|
onContextMenu={(e) => !isOpen && handleContextMenu(e, item)}
|
||||||
|
>
|
||||||
|
{isRenaming ? (
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
{item.type === 'directory' ?
|
||||||
|
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0"/> :
|
||||||
|
<File className="w-4 h-4 text-muted-foreground flex-shrink-0"/>}
|
||||||
|
<Input
|
||||||
|
value={renamingItem.newName}
|
||||||
|
onChange={(e) => setRenamingItem(prev => prev ? {...prev, newName: e.target.value} : null)}
|
||||||
|
className="flex-1 h-6 text-sm bg-[#23232a] border border-[#434345] text-white"
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleRename(item, renamingItem.newName);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setRenamingItem(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => handleRename(item, renamingItem.newName)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
|
||||||
|
onClick={() => !isOpen && (item.type === 'directory' ? handlePathChange(item.path) : onOpenFile({
|
||||||
|
name: item.name,
|
||||||
|
path: item.path,
|
||||||
|
isSSH: item.isSSH,
|
||||||
|
sshSessionId: item.sshSessionId
|
||||||
|
}))}
|
||||||
|
>
|
||||||
|
{item.type === 'directory' ?
|
||||||
|
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0"/> :
|
||||||
|
<File className="w-4 h-4 text-muted-foreground flex-shrink-0"/>}
|
||||||
|
<span className="text-sm text-white truncate flex-1 min-w-0">{item.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{item.type === 'file' && (
|
||||||
|
<Button size="icon" variant="ghost" className="h-7 w-7"
|
||||||
|
disabled={isOpen}
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
if (item.isPinned) {
|
||||||
|
await removeFileManagerPinned({
|
||||||
|
name: item.name,
|
||||||
|
path: item.path,
|
||||||
|
hostId: host?.id,
|
||||||
|
isSSH: true,
|
||||||
|
sshSessionId: host?.id.toString()
|
||||||
|
});
|
||||||
|
setFiles(files.map(f =>
|
||||||
|
f.path === item.path ? { ...f, isPinned: false } : f
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
await addFileManagerPinned({
|
||||||
|
name: item.name,
|
||||||
|
path: item.path,
|
||||||
|
hostId: host?.id,
|
||||||
|
isSSH: true,
|
||||||
|
sshSessionId: host?.id.toString()
|
||||||
|
});
|
||||||
|
setFiles(files.map(f =>
|
||||||
|
f.path === item.path ? { ...f, isPinned: true } : f
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pin className={`w-1 h-1 ${item.isPinned ? 'text-yellow-400 fill-current' : 'text-muted-foreground'}`}/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!isOpen && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleContextMenu(e, item);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MoreVertical className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{contextMenu.visible && contextMenu.item && (
|
||||||
|
<div
|
||||||
|
className="fixed z-[99998] bg-[#18181b] border-2 border-[#303032] rounded-lg shadow-xl py-1 min-w-[160px]"
|
||||||
|
style={{
|
||||||
|
left: contextMenu.x,
|
||||||
|
top: contextMenu.y,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="w-full px-3 py-2 text-left text-sm text-white hover:bg-[#2d2d30] flex items-center gap-2"
|
||||||
|
onClick={() => startRename(contextMenu.item)}
|
||||||
|
>
|
||||||
|
<Edit3 className="w-4 h-4" />
|
||||||
|
Rename
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="w-full px-3 py-2 text-left text-sm text-red-400 hover:bg-[#2d2d30] flex items-center gap-2"
|
||||||
|
onClick={() => startDelete(contextMenu.item)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export {FileManagerLeftSidebar};
|
||||||
@@ -20,7 +20,7 @@ interface FileItem {
|
|||||||
isStarred?: boolean;
|
isStarred?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ConfigFileSidebarViewerProps {
|
interface FileManagerLeftSidebarVileViewerProps {
|
||||||
sshConnections: SSHConnection[];
|
sshConnections: SSHConnection[];
|
||||||
onAddSSH: () => void;
|
onAddSSH: () => void;
|
||||||
onConnectSSH: (conn: SSHConnection) => void;
|
onConnectSSH: (conn: SSHConnection) => void;
|
||||||
@@ -41,7 +41,7 @@ interface ConfigFileSidebarViewerProps {
|
|||||||
currentSSH?: SSHConnection;
|
currentSSH?: SSHConnection;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConfigFileSidebarViewer({
|
export function FileManagerLeftSidebarFileViewer({
|
||||||
sshConnections,
|
sshConnections,
|
||||||
onAddSSH,
|
onAddSSH,
|
||||||
onConnectSSH,
|
onConnectSSH,
|
||||||
@@ -60,51 +60,9 @@ export function ConfigFileSidebarViewer({
|
|||||||
onSwitchToLocal,
|
onSwitchToLocal,
|
||||||
onSwitchToSSH,
|
onSwitchToSSH,
|
||||||
currentSSH,
|
currentSSH,
|
||||||
}: ConfigFileSidebarViewerProps) {
|
}: FileManagerLeftSidebarVileViewerProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* SSH Connections */}
|
|
||||||
<div className="p-2 bg-[#18181b] border-b border-[#23232a]">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<span className="text-xs text-muted-foreground font-semibold">SSH Connections</span>
|
|
||||||
<Button size="icon" variant="outline" onClick={onAddSSH} className="ml-2 h-7 w-7">
|
|
||||||
<Plus className="w-4 h-4"/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<Button
|
|
||||||
variant={!isSSHMode ? 'secondary' : 'ghost'}
|
|
||||||
className="w-full justify-start text-left px-2 py-1.5 rounded"
|
|
||||||
onClick={onSwitchToLocal}
|
|
||||||
>
|
|
||||||
<Server className="w-4 h-4 mr-2"/> Local Files
|
|
||||||
</Button>
|
|
||||||
{sshConnections.map((conn) => (
|
|
||||||
<div key={conn.id} className="flex items-center gap-1 group">
|
|
||||||
<Button
|
|
||||||
variant={isSSHMode && currentSSH?.id === conn.id ? 'secondary' : 'ghost'}
|
|
||||||
className="flex-1 justify-start text-left px-2 py-1.5 rounded"
|
|
||||||
onClick={() => onSwitchToSSH(conn)}
|
|
||||||
>
|
|
||||||
<Link2 className="w-4 h-4 mr-2"/>
|
|
||||||
{conn.name || conn.ip}
|
|
||||||
{conn.isPinned && <Pin className="w-3 h-3 ml-1 text-yellow-400"/>}
|
|
||||||
</Button>
|
|
||||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onPinSSH(conn)}>
|
|
||||||
<Pin
|
|
||||||
className={`w-4 h-4 ${conn.isPinned ? 'text-yellow-400' : 'text-muted-foreground'}`}/>
|
|
||||||
</Button>
|
|
||||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onEditSSH(conn)}>
|
|
||||||
<Edit className="w-4 h-4"/>
|
|
||||||
</Button>
|
|
||||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onDeleteSSH(conn)}>
|
|
||||||
<Trash2 className="w-4 h-4 text-red-500"/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* File/Folder Viewer */}
|
|
||||||
<div className="flex-1 bg-[#09090b] p-2 overflow-y-auto">
|
<div className="flex-1 bg-[#09090b] p-2 overflow-y-auto">
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
@@ -119,7 +77,7 @@ export function ConfigFileSidebarViewer({
|
|||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{files.map((item) => (
|
{files.map((item) => (
|
||||||
<Card key={item.path}
|
<Card key={item.path}
|
||||||
className="flex items-center gap-2 px-2 py-1 bg-[#18181b] border border-[#23232a] rounded">
|
className="flex items-center gap-2 px-2 py-1 bg-[#18181b] border-2 border-[#303032] rounded">
|
||||||
<div className="flex items-center gap-2 flex-1 cursor-pointer"
|
<div className="flex items-center gap-2 flex-1 cursor-pointer"
|
||||||
onClick={() => item.type === 'directory' ? onOpenFolder(item) : onOpenFile(item)}>
|
onClick={() => item.type === 'directory' ? onOpenFolder(item) : onOpenFile(item)}>
|
||||||
{item.type === 'directory' ? <Folder className="w-4 h-4 text-blue-400"/> :
|
{item.type === 'directory' ? <Folder className="w-4 h-4 text-blue-400"/> :
|
||||||
624
src/ui/apps/File Manager/FileManagerOperations.tsx
Normal file
@@ -0,0 +1,624 @@
|
|||||||
|
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';
|
||||||
|
import {Separator} from '@/components/ui/separator.tsx';
|
||||||
|
import {
|
||||||
|
Upload,
|
||||||
|
FilePlus,
|
||||||
|
FolderPlus,
|
||||||
|
Trash2,
|
||||||
|
Edit3,
|
||||||
|
X,
|
||||||
|
Check,
|
||||||
|
AlertCircle,
|
||||||
|
FileText,
|
||||||
|
Folder
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {cn} from '@/lib/utils.ts';
|
||||||
|
|
||||||
|
interface FileManagerOperationsProps {
|
||||||
|
currentPath: string;
|
||||||
|
sshSessionId: string | null;
|
||||||
|
onOperationComplete: () => void;
|
||||||
|
onError: (error: string) => void;
|
||||||
|
onSuccess: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileManagerOperations({
|
||||||
|
currentPath,
|
||||||
|
sshSessionId,
|
||||||
|
onOperationComplete,
|
||||||
|
onError,
|
||||||
|
onSuccess
|
||||||
|
}: FileManagerOperationsProps) {
|
||||||
|
const [showUpload, setShowUpload] = useState(false);
|
||||||
|
const [showCreateFile, setShowCreateFile] = useState(false);
|
||||||
|
const [showCreateFolder, setShowCreateFolder] = useState(false);
|
||||||
|
const [showDelete, setShowDelete] = useState(false);
|
||||||
|
const [showRename, setShowRename] = useState(false);
|
||||||
|
|
||||||
|
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
||||||
|
const [newFileName, setNewFileName] = useState('');
|
||||||
|
const [newFolderName, setNewFolderName] = useState('');
|
||||||
|
const [deletePath, setDeletePath] = useState('');
|
||||||
|
const [deleteIsDirectory, setDeleteIsDirectory] = useState(false);
|
||||||
|
const [renamePath, setRenamePath] = useState('');
|
||||||
|
const [renameIsDirectory, setRenameIsDirectory] = useState(false);
|
||||||
|
const [newName, setNewName] = useState('');
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [showTextLabels, setShowTextLabels] = useState(true);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkContainerWidth = () => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
const width = containerRef.current.offsetWidth;
|
||||||
|
setShowTextLabels(width > 240);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkContainerWidth();
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(checkContainerWidth);
|
||||||
|
if (containerRef.current) {
|
||||||
|
resizeObserver.observe(containerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFileUpload = async () => {
|
||||||
|
if (!uploadFile || !sshSessionId) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const content = await uploadFile.text();
|
||||||
|
const {uploadSSHFile} = await import('@/ui/main-axios.ts');
|
||||||
|
|
||||||
|
await uploadSSHFile(sshSessionId, currentPath, uploadFile.name, content);
|
||||||
|
onSuccess(`File "${uploadFile.name}" uploaded successfully`);
|
||||||
|
setShowUpload(false);
|
||||||
|
setUploadFile(null);
|
||||||
|
onOperationComplete();
|
||||||
|
} catch (error: any) {
|
||||||
|
onError(error?.response?.data?.error || 'Failed to upload file');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateFile = async () => {
|
||||||
|
if (!newFileName.trim() || !sshSessionId) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const {createSSHFile} = await import('@/ui/main-axios.ts');
|
||||||
|
|
||||||
|
await createSSHFile(sshSessionId, currentPath, newFileName.trim());
|
||||||
|
onSuccess(`File "${newFileName.trim()}" created successfully`);
|
||||||
|
setShowCreateFile(false);
|
||||||
|
setNewFileName('');
|
||||||
|
onOperationComplete();
|
||||||
|
} catch (error: any) {
|
||||||
|
onError(error?.response?.data?.error || 'Failed to create file');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateFolder = async () => {
|
||||||
|
if (!newFolderName.trim() || !sshSessionId) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const {createSSHFolder} = await import('@/ui/main-axios.ts');
|
||||||
|
|
||||||
|
await createSSHFolder(sshSessionId, currentPath, newFolderName.trim());
|
||||||
|
onSuccess(`Folder "${newFolderName.trim()}" created successfully`);
|
||||||
|
setShowCreateFolder(false);
|
||||||
|
setNewFolderName('');
|
||||||
|
onOperationComplete();
|
||||||
|
} catch (error: any) {
|
||||||
|
onError(error?.response?.data?.error || 'Failed to create folder');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deletePath || !sshSessionId) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const {deleteSSHItem} = await import('@/ui/main-axios.ts');
|
||||||
|
|
||||||
|
await deleteSSHItem(sshSessionId, deletePath, deleteIsDirectory);
|
||||||
|
onSuccess(`${deleteIsDirectory ? 'Folder' : 'File'} deleted successfully`);
|
||||||
|
setShowDelete(false);
|
||||||
|
setDeletePath('');
|
||||||
|
setDeleteIsDirectory(false);
|
||||||
|
onOperationComplete();
|
||||||
|
} catch (error: any) {
|
||||||
|
onError(error?.response?.data?.error || 'Failed to delete item');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRename = async () => {
|
||||||
|
if (!renamePath || !newName.trim() || !sshSessionId) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const {renameSSHItem} = await import('@/ui/main-axios.ts');
|
||||||
|
|
||||||
|
await renameSSHItem(sshSessionId, renamePath, newName.trim());
|
||||||
|
onSuccess(`${renameIsDirectory ? 'Folder' : 'File'} renamed successfully`);
|
||||||
|
setShowRename(false);
|
||||||
|
setRenamePath('');
|
||||||
|
setRenameIsDirectory(false);
|
||||||
|
setNewName('');
|
||||||
|
onOperationComplete();
|
||||||
|
} catch (error: any) {
|
||||||
|
onError(error?.response?.data?.error || 'Failed to rename item');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openFileDialog = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
setUploadFile(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetStates = () => {
|
||||||
|
setShowUpload(false);
|
||||||
|
setShowCreateFile(false);
|
||||||
|
setShowCreateFolder(false);
|
||||||
|
setShowDelete(false);
|
||||||
|
setShowRename(false);
|
||||||
|
setUploadFile(null);
|
||||||
|
setNewFileName('');
|
||||||
|
setNewFolderName('');
|
||||||
|
setDeletePath('');
|
||||||
|
setDeleteIsDirectory(false);
|
||||||
|
setRenamePath('');
|
||||||
|
setRenameIsDirectory(false);
|
||||||
|
setNewName('');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!sshSessionId) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 text-center">
|
||||||
|
<AlertCircle className="w-8 h-8 text-muted-foreground mx-auto mb-2"/>
|
||||||
|
<p className="text-sm text-muted-foreground">Connect to SSH to use file operations</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="p-4 space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowUpload(true)}
|
||||||
|
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
||||||
|
title="Upload File"
|
||||||
|
>
|
||||||
|
<Upload className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")}/>
|
||||||
|
{showTextLabels && <span className="truncate">Upload File</span>}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowCreateFile(true)}
|
||||||
|
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
||||||
|
title="New File"
|
||||||
|
>
|
||||||
|
<FilePlus className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")}/>
|
||||||
|
{showTextLabels && <span className="truncate">New File</span>}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowCreateFolder(true)}
|
||||||
|
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
||||||
|
title="New Folder"
|
||||||
|
>
|
||||||
|
<FolderPlus className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")}/>
|
||||||
|
{showTextLabels && <span className="truncate">New Folder</span>}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowRename(true)}
|
||||||
|
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
||||||
|
title="Rename"
|
||||||
|
>
|
||||||
|
<Edit3 className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")}/>
|
||||||
|
{showTextLabels && <span className="truncate">Rename</span>}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowDelete(true)}
|
||||||
|
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30] col-span-2"
|
||||||
|
title="Delete Item"
|
||||||
|
>
|
||||||
|
<Trash2 className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")}/>
|
||||||
|
{showTextLabels && <span className="truncate">Delete Item</span>}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[#141416] border-2 border-[#373739] rounded-md p-3">
|
||||||
|
<div className="flex items-start gap-2 text-sm">
|
||||||
|
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5"/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="text-muted-foreground block mb-1">Current Path:</span>
|
||||||
|
<span className="text-white font-mono text-xs break-all leading-relaxed">{currentPath}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="p-0.25 bg-[#303032]"/>
|
||||||
|
|
||||||
|
{showUpload && (
|
||||||
|
<Card className="bg-[#18181b] border-2 border-[#303032] p-3 sm:p-4">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2 mb-1">
|
||||||
|
<Upload className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0"/>
|
||||||
|
<span className="break-words">Upload File</span>
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-muted-foreground break-words">
|
||||||
|
Max: 100MB (JSON) / 200MB (Binary)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowUpload(false)}
|
||||||
|
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4"/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="border-2 border-dashed border-[#434345] rounded-lg p-4 text-center">
|
||||||
|
{uploadFile ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<FileText className="w-12 h-12 text-blue-400 mx-auto"/>
|
||||||
|
<p className="text-white font-medium text-sm break-words px-2">{uploadFile.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{(uploadFile.size / 1024).toFixed(2)} KB
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setUploadFile(null)}
|
||||||
|
className="w-full text-sm h-8"
|
||||||
|
>
|
||||||
|
Remove File
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Upload className="w-12 h-12 text-muted-foreground mx-auto"/>
|
||||||
|
<p className="text-white text-sm break-words px-2">Click to select a file</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={openFileDialog}
|
||||||
|
className="w-full text-sm h-8"
|
||||||
|
>
|
||||||
|
Choose File
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
className="hidden"
|
||||||
|
accept="*/*"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleFileUpload}
|
||||||
|
disabled={!uploadFile || isLoading}
|
||||||
|
className="w-full text-sm h-9"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Uploading...' : 'Upload File'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowUpload(false)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full text-sm h-9"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showCreateFile && (
|
||||||
|
<Card className="bg-[#18181b] border-2 border-[#303032] p-3 sm:p-4">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
|
||||||
|
<FilePlus className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0"/>
|
||||||
|
<span className="break-words">Create New File</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowCreateFile(false)}
|
||||||
|
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4"/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-white mb-2 block">
|
||||||
|
File Name
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
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 text-sm"
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleCreateFile()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateFile}
|
||||||
|
disabled={!newFileName.trim() || isLoading}
|
||||||
|
className="w-full text-sm h-9"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Creating...' : 'Create File'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowCreateFile(false)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full text-sm h-9"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showCreateFolder && (
|
||||||
|
<Card className="bg-[#18181b] border-2 border-[#303032] p-3">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-base font-semibold text-white flex items-center gap-2">
|
||||||
|
<FolderPlus className="w-6 h-6 flex-shrink-0"/>
|
||||||
|
<span className="break-words">Create New Folder</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowCreateFolder(false)}
|
||||||
|
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4"/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-white mb-2 block">
|
||||||
|
Folder Name
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={newFolderName}
|
||||||
|
onChange={(e) => setNewFolderName(e.target.value)}
|
||||||
|
placeholder="Enter folder name"
|
||||||
|
className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleCreateFolder()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateFolder}
|
||||||
|
disabled={!newFolderName.trim() || isLoading}
|
||||||
|
className="w-full text-sm h-9"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Creating...' : 'Create Folder'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowCreateFolder(false)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full text-sm h-9"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showDelete && (
|
||||||
|
<Card className="bg-[#18181b] border-2 border-[#303032] p-3">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-base font-semibold text-white flex items-center gap-2">
|
||||||
|
<Trash2 className="w-6 h-6 text-red-400 flex-shrink-0"/>
|
||||||
|
<span className="break-words">Delete Item</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowDelete(false)}
|
||||||
|
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4"/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="bg-red-900/20 border border-red-500/30 rounded-lg p-3">
|
||||||
|
<div className="flex items-start gap-2 text-red-300">
|
||||||
|
<AlertCircle className="w-5 h-5 flex-shrink-0"/>
|
||||||
|
<span className="text-sm font-medium break-words">Warning: This action cannot be undone</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-white mb-2 block">
|
||||||
|
Item Path
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={deletePath}
|
||||||
|
onChange={(e) => setDeletePath(e.target.value)}
|
||||||
|
placeholder="Enter full path to item"
|
||||||
|
className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="deleteIsDirectory"
|
||||||
|
checked={deleteIsDirectory}
|
||||||
|
onChange={(e) => setDeleteIsDirectory(e.target.checked)}
|
||||||
|
className="rounded border-[#434345] bg-[#23232a] mt-0.5 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<label htmlFor="deleteIsDirectory" className="text-sm text-white break-words">
|
||||||
|
This is a directory (will delete recursively)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={!deletePath || isLoading}
|
||||||
|
variant="destructive"
|
||||||
|
className="w-full text-sm h-9"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Deleting...' : 'Delete Item'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowDelete(false)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full text-sm h-9"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showRename && (
|
||||||
|
<Card className="bg-[#18181b] border-2 border-[#303032] p-3">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-base font-semibold text-white flex items-center gap-2">
|
||||||
|
<Edit3 className="w-6 h-6 flex-shrink-0"/>
|
||||||
|
<span className="break-words">Rename Item</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowRename(false)}
|
||||||
|
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4"/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-white mb-2 block">
|
||||||
|
Current Path
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={renamePath}
|
||||||
|
onChange={(e) => setRenamePath(e.target.value)}
|
||||||
|
placeholder="Enter current path to item"
|
||||||
|
className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-white mb-2 block">
|
||||||
|
New Name
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
placeholder="Enter new name"
|
||||||
|
className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleRename()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="renameIsDirectory"
|
||||||
|
checked={renameIsDirectory}
|
||||||
|
onChange={(e) => setRenameIsDirectory(e.target.checked)}
|
||||||
|
className="rounded border-[#434345] bg-[#23232a] mt-0.5 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<label htmlFor="renameIsDirectory" className="text-sm text-white break-words">
|
||||||
|
This is a directory
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleRename}
|
||||||
|
disabled={!renamePath || !newName.trim() || isLoading}
|
||||||
|
className="w-full text-sm h-9"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Renaming...' : 'Rename Item'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowRename(false)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full text-sm h-9"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/ui/apps/File Manager/FileManagerTabList.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {Button} from '@/components/ui/button.tsx';
|
||||||
|
import {X, Home} from 'lucide-react';
|
||||||
|
|
||||||
|
interface FileManagerTab {
|
||||||
|
id: string | number;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileManagerTabList {
|
||||||
|
tabs: FileManagerTab[];
|
||||||
|
activeTab: string | number;
|
||||||
|
setActiveTab: (tab: string | number) => void;
|
||||||
|
closeTab: (tab: string | number) => void;
|
||||||
|
onHomeClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileManagerTabList({tabs, activeTab, setActiveTab, closeTab, onHomeClick}: FileManagerTabList) {
|
||||||
|
return (
|
||||||
|
<div className="inline-flex items-center h-full gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={onHomeClick}
|
||||||
|
variant="outline"
|
||||||
|
className={`ml-1 h-8 rounded-md flex items-center !px-2 border-1 border-[#303032] ${activeTab === 'home' ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
|
||||||
|
>
|
||||||
|
<Home className="w-4 h-4"/>
|
||||||
|
</Button>
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const isActive = tab.id === activeTab;
|
||||||
|
return (
|
||||||
|
<div key={tab.id} className="inline-flex rounded-md shadow-sm" role="group">
|
||||||
|
<Button
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
variant="outline"
|
||||||
|
className={`h-8 rounded-r-none !px-2 border-1 border-[#303032] ${isActive ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
|
||||||
|
>
|
||||||
|
{tab.title}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => closeTab(tab.id)}
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 rounded-l-none p-0 !w-9 border-1 border-[#303032]"
|
||||||
|
>
|
||||||
|
<X className="!w-4 !h-4" strokeWidth={2}/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import React, {useState} from "react";
|
import React, {useState} from "react";
|
||||||
import {SSHManagerSidebar} from "@/apps/SSH/Manager/SSHManagerSidebar.tsx";
|
import {HostManagerHostViewer} from "@/ui/apps/Host Manager/HostManagerHostViewer.tsx"
|
||||||
import {SSHManagerHostViewer} from "@/apps/SSH/Manager/SSHManagerHostViewer.tsx"
|
|
||||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
|
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
|
||||||
import {Separator} from "@/components/ui/separator.tsx";
|
import {Separator} from "@/components/ui/separator.tsx";
|
||||||
import {SSHManagerHostEditor} from "@/apps/SSH/Manager/SSHManagerHostEditor.tsx";
|
import {HostManagerHostEditor} from "@/ui/apps/Host Manager/HostManagerHostEditor.tsx";
|
||||||
|
import {useSidebar} from "@/components/ui/sidebar.tsx";
|
||||||
|
|
||||||
interface ConfigEditorProps {
|
interface HostManagerProps {
|
||||||
onSelectView: (view: string) => void;
|
onSelectView: (view: string) => void;
|
||||||
|
isTopbarOpen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SSHHost {
|
interface SSHHost {
|
||||||
@@ -25,16 +26,17 @@ interface SSHHost {
|
|||||||
keyType?: string;
|
keyType?: string;
|
||||||
enableTerminal: boolean;
|
enableTerminal: boolean;
|
||||||
enableTunnel: boolean;
|
enableTunnel: boolean;
|
||||||
enableConfigEditor: boolean;
|
enableFileManager: boolean;
|
||||||
defaultPath: string;
|
defaultPath: string;
|
||||||
tunnelConnections: any[];
|
tunnelConnections: any[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SSHManager({onSelectView}: ConfigEditorProps): React.ReactElement {
|
export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): React.ReactElement {
|
||||||
const [activeTab, setActiveTab] = useState("host_viewer");
|
const [activeTab, setActiveTab] = useState("host_viewer");
|
||||||
const [editingHost, setEditingHost] = useState<SSHHost | null>(null);
|
const [editingHost, setEditingHost] = useState<SSHHost | null>(null);
|
||||||
|
const {state: sidebarState} = useSidebar();
|
||||||
|
|
||||||
const handleEditHost = (host: SSHHost) => {
|
const handleEditHost = (host: SSHHost) => {
|
||||||
setEditingHost(host);
|
setEditingHost(host);
|
||||||
@@ -53,33 +55,39 @@ export function SSHManager({onSelectView}: ConfigEditorProps): React.ReactElemen
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||||
|
const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8;
|
||||||
|
const bottomMarginPx = 8;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SSHManagerSidebar
|
<div className="w-full">
|
||||||
onSelectView={onSelectView}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex w-screen h-screen overflow-hidden">
|
|
||||||
<div className="w-[256px]"/>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="flex-1 bg-[#18181b] m-[35px] text-white p-4 rounded-md w-[1200px] border h-[calc(100vh-70px)] flex flex-col min-h-0">
|
className="bg-[#18181b] text-white p-4 pt-0 rounded-lg border-2 border-[#303032] flex flex-col min-h-0 overflow-hidden"
|
||||||
|
style={{
|
||||||
|
marginLeft: leftMarginPx,
|
||||||
|
marginRight: 17,
|
||||||
|
marginTop: topMarginPx,
|
||||||
|
marginBottom: bottomMarginPx,
|
||||||
|
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Tabs value={activeTab} onValueChange={handleTabChange}
|
<Tabs value={activeTab} onValueChange={handleTabChange}
|
||||||
className="flex-1 flex flex-col h-full min-h-0">
|
className="flex-1 flex flex-col h-full min-h-0">
|
||||||
<TabsList>
|
<TabsList className="bg-[#18181b] border-2 border-[#303032] mt-1.5">
|
||||||
<TabsTrigger value="host_viewer">Host Viewer</TabsTrigger>
|
<TabsTrigger value="host_viewer">Host Viewer</TabsTrigger>
|
||||||
<TabsTrigger value="add_host">
|
<TabsTrigger value="add_host">
|
||||||
{editingHost ? "Edit Host" : "Add Host"}
|
{editingHost ? "Edit Host" : "Add Host"}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="host_viewer" className="flex-1 flex flex-col h-full min-h-0">
|
<TabsContent value="host_viewer" className="flex-1 flex flex-col h-full min-h-0">
|
||||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
<Separator className="p-0.25 -mt-0.5 mb-1"/>
|
||||||
<SSHManagerHostViewer onEditHost={handleEditHost}/>
|
<HostManagerHostViewer onEditHost={handleEditHost}/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="add_host" className="flex-1 flex flex-col h-full min-h-0">
|
<TabsContent value="add_host" className="flex-1 flex flex-col h-full min-h-0">
|
||||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
<Separator className="p-0.25 -mt-0.5 mb-1"/>
|
||||||
<div className="flex flex-col h-full min-h-0">
|
<div className="flex flex-col h-full min-h-0">
|
||||||
<SSHManagerHostEditor
|
<HostManagerHostEditor
|
||||||
editingHost={editingHost}
|
editingHost={editingHost}
|
||||||
onFormSubmit={handleFormSubmit}
|
onFormSubmit={handleFormSubmit}
|
||||||
/>
|
/>
|
||||||
@@ -19,7 +19,7 @@ import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx
|
|||||||
import React, {useEffect, useRef, useState} from "react";
|
import React, {useEffect, useRef, useState} from "react";
|
||||||
import {Switch} from "@/components/ui/switch.tsx";
|
import {Switch} from "@/components/ui/switch.tsx";
|
||||||
import {Alert, AlertDescription} from "@/components/ui/alert.tsx";
|
import {Alert, AlertDescription} from "@/components/ui/alert.tsx";
|
||||||
import {createSSHHost, updateSSHHost, getSSHHosts} from '@/apps/SSH/ssh-axios';
|
import {createSSHHost, updateSSHHost, getSSHHosts} from '@/ui/main-axios.ts';
|
||||||
|
|
||||||
interface SSHHost {
|
interface SSHHost {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -37,7 +37,7 @@ interface SSHHost {
|
|||||||
keyType?: string;
|
keyType?: string;
|
||||||
enableTerminal: boolean;
|
enableTerminal: boolean;
|
||||||
enableTunnel: boolean;
|
enableTunnel: boolean;
|
||||||
enableConfigEditor: boolean;
|
enableFileManager: boolean;
|
||||||
defaultPath: string;
|
defaultPath: string;
|
||||||
tunnelConnections: any[];
|
tunnelConnections: any[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -49,7 +49,7 @@ interface SSHManagerHostEditorProps {
|
|||||||
onFormSubmit?: () => void;
|
onFormSubmit?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHostEditorProps) {
|
export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHostEditorProps) {
|
||||||
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
||||||
const [folders, setFolders] = useState<string[]>([]);
|
const [folders, setFolders] = useState<string[]>([]);
|
||||||
const [sshConfigurations, setSshConfigurations] = useState<string[]>([]);
|
const [sshConfigurations, setSshConfigurations] = useState<string[]>([]);
|
||||||
@@ -120,7 +120,7 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
|
|||||||
retryInterval: z.coerce.number().min(1).max(3600).default(10),
|
retryInterval: z.coerce.number().min(1).max(3600).default(10),
|
||||||
autoStart: z.boolean().default(false),
|
autoStart: z.boolean().default(false),
|
||||||
})).default([]),
|
})).default([]),
|
||||||
enableConfigEditor: z.boolean().default(true),
|
enableFileManager: z.boolean().default(true),
|
||||||
defaultPath: z.string().optional(),
|
defaultPath: z.string().optional(),
|
||||||
}).superRefine((data, ctx) => {
|
}).superRefine((data, ctx) => {
|
||||||
if (data.authType === 'password') {
|
if (data.authType === 'password') {
|
||||||
@@ -178,7 +178,7 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
|
|||||||
keyType: "auto",
|
keyType: "auto",
|
||||||
enableTerminal: editingHost?.enableTerminal !== false,
|
enableTerminal: editingHost?.enableTerminal !== false,
|
||||||
enableTunnel: editingHost?.enableTunnel !== false,
|
enableTunnel: editingHost?.enableTunnel !== false,
|
||||||
enableConfigEditor: editingHost?.enableConfigEditor !== false,
|
enableFileManager: editingHost?.enableFileManager !== false,
|
||||||
defaultPath: editingHost?.defaultPath || "/",
|
defaultPath: editingHost?.defaultPath || "/",
|
||||||
tunnelConnections: editingHost?.tunnelConnections || [],
|
tunnelConnections: editingHost?.tunnelConnections || [],
|
||||||
}
|
}
|
||||||
@@ -205,7 +205,7 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
|
|||||||
keyType: (editingHost.keyType as any) || "auto",
|
keyType: (editingHost.keyType as any) || "auto",
|
||||||
enableTerminal: editingHost.enableTerminal !== false,
|
enableTerminal: editingHost.enableTerminal !== false,
|
||||||
enableTunnel: editingHost.enableTunnel !== false,
|
enableTunnel: editingHost.enableTunnel !== false,
|
||||||
enableConfigEditor: editingHost.enableConfigEditor !== false,
|
enableFileManager: editingHost.enableFileManager !== false,
|
||||||
defaultPath: editingHost.defaultPath || "/",
|
defaultPath: editingHost.defaultPath || "/",
|
||||||
tunnelConnections: editingHost.tunnelConnections || [],
|
tunnelConnections: editingHost.tunnelConnections || [],
|
||||||
});
|
});
|
||||||
@@ -227,7 +227,7 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
|
|||||||
keyType: "auto",
|
keyType: "auto",
|
||||||
enableTerminal: true,
|
enableTerminal: true,
|
||||||
enableTunnel: true,
|
enableTunnel: true,
|
||||||
enableConfigEditor: true,
|
enableFileManager: true,
|
||||||
defaultPath: "/",
|
defaultPath: "/",
|
||||||
tunnelConnections: [],
|
tunnelConnections: [],
|
||||||
});
|
});
|
||||||
@@ -251,6 +251,8 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
|
|||||||
if (onFormSubmit) {
|
if (onFormSubmit) {
|
||||||
onFormSubmit();
|
onFormSubmit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert('Failed to save host. Please try again.');
|
alert('Failed to save host. Please try again.');
|
||||||
}
|
}
|
||||||
@@ -388,15 +390,15 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
|
|||||||
<div className="flex-1 flex flex-col h-full min-h-0 w-full">
|
<div className="flex-1 flex flex-col h-full min-h-0 w-full">
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0 h-full">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0 h-full">
|
||||||
<ScrollArea className="flex-1 min-h-0 w-full my-1">
|
<ScrollArea className="flex-1 min-h-0 w-full my-1 pb-2">
|
||||||
<Tabs defaultValue="general" className="w-full">
|
<Tabs defaultValue="general" className="w-full">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="general">General</TabsTrigger>
|
<TabsTrigger value="general">General</TabsTrigger>
|
||||||
<TabsTrigger value="terminal">Terminal</TabsTrigger>
|
<TabsTrigger value="terminal">Terminal</TabsTrigger>
|
||||||
<TabsTrigger value="tunnel">Tunnel</TabsTrigger>
|
<TabsTrigger value="tunnel">Tunnel</TabsTrigger>
|
||||||
<TabsTrigger value="config_editor">Config Editor</TabsTrigger>
|
<TabsTrigger value="file_manager">File Manager</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="general">
|
<TabsContent value="general" className="pt-2">
|
||||||
<FormLabel className="mb-3 font-bold">Connection Details</FormLabel>
|
<FormLabel className="mb-3 font-bold">Connection Details</FormLabel>
|
||||||
<div className="grid grid-cols-12 gap-4">
|
<div className="grid grid-cols-12 gap-4">
|
||||||
<FormField
|
<FormField
|
||||||
@@ -584,7 +586,7 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Password</FormLabel>
|
<FormLabel>Password</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="password" {...field} />
|
<Input type="password" placeholder="password" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@@ -809,7 +811,9 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
|
|||||||
render={({field: sourcePortField}) => (
|
render={({field: sourcePortField}) => (
|
||||||
<FormItem className="col-span-4">
|
<FormItem className="col-span-4">
|
||||||
<FormLabel>Source Port
|
<FormLabel>Source Port
|
||||||
(Local)</FormLabel>
|
(Source refers to the Current
|
||||||
|
Connection Details in the
|
||||||
|
General tab)</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="22" {...sourcePortField} />
|
placeholder="22" {...sourcePortField} />
|
||||||
@@ -984,13 +988,13 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="config_editor">
|
<TabsContent value="file_manager">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="enableConfigEditor"
|
name="enableFileManager"
|
||||||
render={({field}) => (
|
render={({field}) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Enable Config Editor</FormLabel>
|
<FormLabel>Enable File Manager</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Switch
|
<Switch
|
||||||
checked={field.value}
|
checked={field.value}
|
||||||
@@ -998,13 +1002,13 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
|
|||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Enable/disable host visibility in Config Editor tab.
|
Enable/disable host visibility in File Manager tab.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{form.watch('enableConfigEditor') && (
|
{form.watch('enableFileManager') && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -1016,7 +1020,7 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
|
|||||||
<Input placeholder="/home" {...field} />
|
<Input placeholder="/home" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>Set default directory shown when connected via
|
<FormDescription>Set default directory shown when connected via
|
||||||
Config Editor</FormDescription>
|
File Manager</FormDescription>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -1025,9 +1029,18 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
<footer className="shrink-0 w-full">
|
<footer className="shrink-0 w-full pb-0">
|
||||||
<Separator className="p-0.25 mt-1 mb-3"/>
|
<Separator className="p-0.25"/>
|
||||||
<Button type="submit" variant="outline">{editingHost ? "Update Host" : "Add Host"}</Button>
|
<Button
|
||||||
|
className=""
|
||||||
|
type="submit"
|
||||||
|
variant="outline"
|
||||||
|
style={{
|
||||||
|
transform: 'translateY(8px)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{editingHost ? "Update Host" : "Add Host"}
|
||||||
|
</Button>
|
||||||
</footer>
|
</footer>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
732
src/ui/apps/Host Manager/HostManagerHostViewer.tsx
Normal file
@@ -0,0 +1,732 @@
|
|||||||
|
import React, {useState, useEffect, useMemo} from "react";
|
||||||
|
import {Card, CardContent} from "@/components/ui/card";
|
||||||
|
import {Button} from "@/components/ui/button";
|
||||||
|
import {Badge} from "@/components/ui/badge";
|
||||||
|
import {ScrollArea} from "@/components/ui/scroll-area";
|
||||||
|
import {Input} from "@/components/ui/input";
|
||||||
|
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion";
|
||||||
|
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
|
||||||
|
import {getSSHHosts, deleteSSHHost, bulkImportSSHHosts} from "@/ui/main-axios.ts";
|
||||||
|
import {
|
||||||
|
Edit,
|
||||||
|
Trash2,
|
||||||
|
Server,
|
||||||
|
Folder,
|
||||||
|
Tag,
|
||||||
|
Pin,
|
||||||
|
Terminal,
|
||||||
|
Network,
|
||||||
|
FileEdit,
|
||||||
|
Search,
|
||||||
|
Upload,
|
||||||
|
Info
|
||||||
|
} from "lucide-react";
|
||||||
|
import {Separator} from "@/components/ui/separator.tsx";
|
||||||
|
|
||||||
|
interface SSHHost {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
folder: string;
|
||||||
|
tags: string[];
|
||||||
|
pin: boolean;
|
||||||
|
authType: string;
|
||||||
|
enableTerminal: boolean;
|
||||||
|
enableTunnel: boolean;
|
||||||
|
enableFileManager: boolean;
|
||||||
|
defaultPath: string;
|
||||||
|
tunnelConnections: any[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SSHManagerHostViewerProps {
|
||||||
|
onEditHost?: (host: SSHHost) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||||
|
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchHosts();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchHosts = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await getSSHHosts();
|
||||||
|
setHosts(data);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to load hosts');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (hostId: number, hostName: string) => {
|
||||||
|
if (window.confirm(`Are you sure you want to delete "${hostName}"?`)) {
|
||||||
|
try {
|
||||||
|
await deleteSSHHost(hostId);
|
||||||
|
await fetchHosts();
|
||||||
|
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to delete host');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (host: SSHHost) => {
|
||||||
|
if (onEditHost) {
|
||||||
|
onEditHost(host);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJsonImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setImporting(true);
|
||||||
|
const text = await file.text();
|
||||||
|
const data = JSON.parse(text);
|
||||||
|
|
||||||
|
if (!Array.isArray(data.hosts) && !Array.isArray(data)) {
|
||||||
|
throw new Error('JSON must contain a "hosts" array or be an array of hosts');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostsArray = Array.isArray(data.hosts) ? data.hosts : data;
|
||||||
|
|
||||||
|
if (hostsArray.length === 0) {
|
||||||
|
throw new Error('No hosts found in JSON file');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hostsArray.length > 100) {
|
||||||
|
throw new Error('Maximum 100 hosts allowed per import');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await bulkImportSSHHosts(hostsArray);
|
||||||
|
|
||||||
|
if (result.success > 0) {
|
||||||
|
alert(`Import completed: ${result.success} successful, ${result.failed} failed${result.errors.length > 0 ? '\n\nErrors:\n' + result.errors.join('\n') : ''}`);
|
||||||
|
await fetchHosts();
|
||||||
|
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||||
|
} else {
|
||||||
|
alert(`Import failed: ${result.errors.join('\n')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to import JSON file';
|
||||||
|
alert(`Import error: ${errorMessage}`);
|
||||||
|
} finally {
|
||||||
|
setImporting(false);
|
||||||
|
event.target.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredAndSortedHosts = useMemo(() => {
|
||||||
|
let filtered = hosts;
|
||||||
|
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
filtered = hosts.filter(host => {
|
||||||
|
const searchableText = [
|
||||||
|
host.name || '',
|
||||||
|
host.username,
|
||||||
|
host.ip,
|
||||||
|
host.folder || '',
|
||||||
|
...(host.tags || []),
|
||||||
|
host.authType,
|
||||||
|
host.defaultPath || ''
|
||||||
|
].join(' ').toLowerCase();
|
||||||
|
return searchableText.includes(query);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered.sort((a, b) => {
|
||||||
|
if (a.pin && !b.pin) return -1;
|
||||||
|
if (!a.pin && b.pin) return 1;
|
||||||
|
|
||||||
|
const aName = a.name || a.username;
|
||||||
|
const bName = b.name || b.username;
|
||||||
|
return aName.localeCompare(bName);
|
||||||
|
});
|
||||||
|
}, [hosts, searchQuery]);
|
||||||
|
|
||||||
|
const hostsByFolder = useMemo(() => {
|
||||||
|
const grouped: { [key: string]: SSHHost[] } = {};
|
||||||
|
|
||||||
|
filteredAndSortedHosts.forEach(host => {
|
||||||
|
const folder = host.folder || 'Uncategorized';
|
||||||
|
if (!grouped[folder]) {
|
||||||
|
grouped[folder] = [];
|
||||||
|
}
|
||||||
|
grouped[folder].push(host);
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedFolders = Object.keys(grouped).sort((a, b) => {
|
||||||
|
if (a === 'Uncategorized') return -1;
|
||||||
|
if (b === 'Uncategorized') return 1;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedGrouped: { [key: string]: SSHHost[] } = {};
|
||||||
|
sortedFolders.forEach(folder => {
|
||||||
|
sortedGrouped[folder] = grouped[folder];
|
||||||
|
});
|
||||||
|
|
||||||
|
return sortedGrouped;
|
||||||
|
}, [filteredAndSortedHosts]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||||
|
<p className="text-muted-foreground">Loading hosts...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-red-500 mb-4">{error}</p>
|
||||||
|
<Button onClick={fetchHosts} variant="outline">
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hosts.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-center">
|
||||||
|
<Server className="h-12 w-12 text-muted-foreground mx-auto mb-4"/>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">No SSH Hosts</h3>
|
||||||
|
<p className="text-muted-foreground mb-4">
|
||||||
|
You haven't added any SSH hosts yet. Click "Add Host" to get started.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full min-h-0">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">SSH Hosts</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{filteredAndSortedHosts.length} hosts
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="relative"
|
||||||
|
onClick={() => document.getElementById('json-import-input')?.click()}
|
||||||
|
disabled={importing}
|
||||||
|
>
|
||||||
|
{importing ? 'Importing...' : 'Import JSON'}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom"
|
||||||
|
className="max-w-sm bg-popover text-popover-foreground border border-border shadow-lg">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="font-semibold text-sm">Import SSH Hosts from JSON</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Upload a JSON file to bulk import multiple SSH hosts (max 100).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const sampleData = {
|
||||||
|
hosts: [
|
||||||
|
{
|
||||||
|
name: "Web Server - Production",
|
||||||
|
ip: "192.168.1.100",
|
||||||
|
port: 22,
|
||||||
|
username: "admin",
|
||||||
|
authType: "password",
|
||||||
|
password: "your_secure_password_here",
|
||||||
|
folder: "Production",
|
||||||
|
tags: ["web", "production", "nginx"],
|
||||||
|
pin: true,
|
||||||
|
enableTerminal: true,
|
||||||
|
enableTunnel: false,
|
||||||
|
enableFileManager: true,
|
||||||
|
defaultPath: "/var/www"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Database Server",
|
||||||
|
ip: "192.168.1.101",
|
||||||
|
port: 22,
|
||||||
|
username: "dbadmin",
|
||||||
|
authType: "key",
|
||||||
|
key: "-----BEGIN OPENSSH PRIVATE KEY-----\nYour SSH private key content here\n-----END OPENSSH PRIVATE KEY-----",
|
||||||
|
keyPassword: "optional_key_passphrase",
|
||||||
|
keyType: "ssh-ed25519",
|
||||||
|
folder: "Production",
|
||||||
|
tags: ["database", "production", "postgresql"],
|
||||||
|
pin: false,
|
||||||
|
enableTerminal: true,
|
||||||
|
enableTunnel: true,
|
||||||
|
enableFileManager: false,
|
||||||
|
tunnelConnections: [
|
||||||
|
{
|
||||||
|
sourcePort: 5432,
|
||||||
|
endpointPort: 5432,
|
||||||
|
endpointHost: "Web Server - Production",
|
||||||
|
maxRetries: 3,
|
||||||
|
retryInterval: 10,
|
||||||
|
autoStart: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const blob = new Blob([JSON.stringify(sampleData, null, 2)], {type: 'application/json'});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'sample-ssh-hosts.json';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Download Sample
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const infoContent = `
|
||||||
|
JSON Import Format Guide
|
||||||
|
|
||||||
|
REQUIRED FIELDS:
|
||||||
|
• ip: Host IP address (string)
|
||||||
|
• port: SSH port (number, 1-65535)
|
||||||
|
• username: SSH username (string)
|
||||||
|
• authType: "password" or "key"
|
||||||
|
|
||||||
|
AUTHENTICATION FIELDS:
|
||||||
|
• password: Required if authType is "password"
|
||||||
|
• key: SSH private key content (string) if authType is "key"
|
||||||
|
• keyPassword: Optional key passphrase
|
||||||
|
• keyType: Key type (auto, ssh-rsa, ssh-ed25519, etc.)
|
||||||
|
|
||||||
|
OPTIONAL FIELDS:
|
||||||
|
• name: Display name (string)
|
||||||
|
• folder: Organization folder (string)
|
||||||
|
• tags: Array of tag strings
|
||||||
|
• pin: Pin to top (boolean)
|
||||||
|
• enableTerminal: Show in Terminal tab (boolean, default: true)
|
||||||
|
• enableTunnel: Show in Tunnel tab (boolean, default: true)
|
||||||
|
• enableFileManager: Show in File Manager tab (boolean, default: true)
|
||||||
|
• defaultPath: Default directory path (string)
|
||||||
|
|
||||||
|
TUNNEL CONFIGURATION:
|
||||||
|
• tunnelConnections: Array of tunnel objects
|
||||||
|
- sourcePort: Local port (number)
|
||||||
|
- endpointPort: Remote port (number)
|
||||||
|
- endpointHost: Target host name (string)
|
||||||
|
- maxRetries: Retry attempts (number, default: 3)
|
||||||
|
- retryInterval: Retry delay in seconds (number, default: 10)
|
||||||
|
- autoStart: Auto-start on launch (boolean, default: false)
|
||||||
|
|
||||||
|
EXAMPLE STRUCTURE:
|
||||||
|
{
|
||||||
|
"hosts": [
|
||||||
|
{
|
||||||
|
"name": "Web Server",
|
||||||
|
"ip": "192.168.1.100",
|
||||||
|
"port": 22,
|
||||||
|
"username": "admin",
|
||||||
|
"authType": "password",
|
||||||
|
"password": "your_password",
|
||||||
|
"folder": "Production",
|
||||||
|
"tags": ["web", "production"],
|
||||||
|
"pin": true,
|
||||||
|
"enableTerminal": true,
|
||||||
|
"enableTunnel": false,
|
||||||
|
"enableFileManager": true,
|
||||||
|
"defaultPath": "/var/www"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
• Maximum 100 hosts per import
|
||||||
|
• File should contain a "hosts" array or be an array of host objects
|
||||||
|
• All fields are copyable for easy reference
|
||||||
|
`;
|
||||||
|
|
||||||
|
const newWindow = window.open('', '_blank', 'width=600,height=800,scrollbars=yes,resizable=yes');
|
||||||
|
if (newWindow) {
|
||||||
|
newWindow.document.write(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>SSH JSON Import Guide</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
margin: 20px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #ffffff;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
background: #2a2a2a;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #404040;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
background: #404040;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: 'Consolas', 'Monaco', monospace;
|
||||||
|
}
|
||||||
|
h1 { color: #60a5fa; border-bottom: 2px solid #60a5fa; padding-bottom: 10px; }
|
||||||
|
h2 { color: #34d399; margin-top: 25px; }
|
||||||
|
.field-group { margin: 15px 0; }
|
||||||
|
.field-item { margin: 8px 0; }
|
||||||
|
.copy-btn {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
.copy-btn:hover { background: #2563eb; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>SSH JSON Import Format Guide</h1>
|
||||||
|
<p>Use this guide to create JSON files for bulk importing SSH hosts. All examples are copyable.</p>
|
||||||
|
|
||||||
|
<h2>Required Fields</h2>
|
||||||
|
<div class="field-group">
|
||||||
|
<div class="field-item">
|
||||||
|
<code>ip</code> - Host IP address (string)
|
||||||
|
<button class="copy-btn" onclick="navigator.clipboard.writeText('ip')">Copy</button>
|
||||||
|
</div>
|
||||||
|
<div class="field-item">
|
||||||
|
<code>port</code> - SSH port (number, 1-65535)
|
||||||
|
<button class="copy-btn" onclick="navigator.clipboard.writeText('port')">Copy</button>
|
||||||
|
</div>
|
||||||
|
<div class="field-item">
|
||||||
|
<code>username</code> - SSH username (string)
|
||||||
|
<button class="copy-btn" onclick="navigator.clipboard.writeText('username')">Copy</button>
|
||||||
|
</div>
|
||||||
|
<div class="field-item">
|
||||||
|
<code>authType</code> - "password" or "key"
|
||||||
|
<button class="copy-btn" onclick="navigator.clipboard.writeText('authType')">Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Authentication Fields</h2>
|
||||||
|
<div class="field-group">
|
||||||
|
<div class="field-item">
|
||||||
|
<code>password</code> - Required if authType is "password"
|
||||||
|
<button class="copy-btn" onclick="navigator.clipboard.writeText('password')">Copy</button>
|
||||||
|
</div>
|
||||||
|
<div class="field-item">
|
||||||
|
<code>key</code> - SSH private key content (string) if authType is "key"
|
||||||
|
<button class="copy-btn" onclick="navigator.clipboard.writeText('key')">Copy</button>
|
||||||
|
</div>
|
||||||
|
<div class="field-item">
|
||||||
|
<code>keyPassword</code> - Optional key passphrase
|
||||||
|
<button class="copy-btn" onclick="navigator.clipboard.writeText('keyPassword')">Copy</button>
|
||||||
|
</div>
|
||||||
|
<div class="field-item">
|
||||||
|
<code>keyType</code> - Key type (auto, ssh-rsa, ssh-ed25519, etc.)
|
||||||
|
<button class="copy-btn" onclick="navigator.clipboard.writeText('keyType')">Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Optional Fields</h2>
|
||||||
|
<div class="field-group">
|
||||||
|
<div class="field-item">
|
||||||
|
<code>name</code> - Display name (string)
|
||||||
|
<button class="copy-btn" onclick="navigator.clipboard.writeText('name')">Copy</button>
|
||||||
|
</div>
|
||||||
|
<div class="field-item">
|
||||||
|
<code>folder</code> - Organization folder (string)
|
||||||
|
<button class="copy-btn" onclick="navigator.clipboard.writeText('folder')">Copy</button>
|
||||||
|
</div>
|
||||||
|
<div class="field-item">
|
||||||
|
<code>tags</code> - Array of tag strings
|
||||||
|
<button class="copy-btn" onclick="navigator.clipboard.writeText('tags')">Copy</button>
|
||||||
|
</div>
|
||||||
|
<div class="field-item">
|
||||||
|
<code>pin</code> - Pin to top (boolean)
|
||||||
|
<button class="copy-btn" onclick="navigator.clipboard.writeText('pin')">Copy</button>
|
||||||
|
</div>
|
||||||
|
<div class="field-item">
|
||||||
|
<code>enableTerminal</code> - Show in Terminal tab (boolean, default: true)
|
||||||
|
<button class="copy-btn" onclick="navigator.clipboard.writeText('enableTerminal')">Copy</button>
|
||||||
|
</div>
|
||||||
|
<div class="field-item">
|
||||||
|
<code>enableTunnel</code> - Show in Tunnel tab (boolean, default: true)
|
||||||
|
<button class="copy-btn" onclick="navigator.clipboard.writeText('enableTunnel')">Copy</button>
|
||||||
|
</div>
|
||||||
|
<div class="field-item">
|
||||||
|
<code>enableFileManager</code> - Show in File Manager tab (boolean, default: true)
|
||||||
|
<button class="copy-btn" onclick="navigator.clipboard.writeText('enableFileManager')">Copy</button>
|
||||||
|
</div>
|
||||||
|
<div class="field-item">
|
||||||
|
<code>defaultPath</code> - Default directory path (string)
|
||||||
|
<button class="copy-btn" onclick="navigator.clipboard.writeText('defaultPath')">Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Tunnel Configuration</h2>
|
||||||
|
<div class="field-group">
|
||||||
|
<div class="field-item">
|
||||||
|
<code>tunnelConnections</code> - Array of tunnel objects
|
||||||
|
<button class="copy-btn" onclick="navigator.clipboard.writeText('tunnelConnections')">Copy</button>
|
||||||
|
</div>
|
||||||
|
<div style="margin-left: 20px;">
|
||||||
|
<div class="field-item">
|
||||||
|
<code>sourcePort</code> - Local port (number)
|
||||||
|
<button class="copy-btn" onclick="navigator.clipboard.writeText('sourcePort')">Copy</button>
|
||||||
|
</div>
|
||||||
|
<div class="field-item">
|
||||||
|
<code>endpointPort</code> - Remote port (number)
|
||||||
|
<button class="copy-btn" onclick="navigator.clipboard.writeText('endpointPort')">Copy</button>
|
||||||
|
</div>
|
||||||
|
<div class="field-item">
|
||||||
|
<code>endpointHost</code> - Target host name (string)
|
||||||
|
<button class="copy-btn" onclick="navigator.clipboard.writeText('endpointHost')">Copy</button>
|
||||||
|
</div>
|
||||||
|
<div class="field-item">
|
||||||
|
<code>maxRetries</code> - Retry attempts (number, default: 3)
|
||||||
|
<button class="copy-btn" onclick="navigator.clipboard.writeText('maxRetries')">Copy</button>
|
||||||
|
</div>
|
||||||
|
<div class="field-item">
|
||||||
|
<code>retryInterval</code> - Retry delay in seconds (number, default: 10)
|
||||||
|
<button class="copy-btn" onclick="navigator.clipboard.writeText('retryInterval')">Copy</button>
|
||||||
|
</div>
|
||||||
|
<div class="field-item">
|
||||||
|
<code>autoStart</code> - Auto-start on launch (boolean, default: false)
|
||||||
|
<button class="copy-btn" onclick="navigator.clipboard.writeText('autoStart')">Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Example JSON Structure</h2>
|
||||||
|
<pre><code>{
|
||||||
|
"hosts": [
|
||||||
|
{
|
||||||
|
"name": "Web Server",
|
||||||
|
"ip": "192.168.1.100",
|
||||||
|
"port": 22,
|
||||||
|
"username": "admin",
|
||||||
|
"authType": "password",
|
||||||
|
"password": "your_password",
|
||||||
|
"folder": "Production",
|
||||||
|
"tags": ["web", "production"],
|
||||||
|
"pin": true,
|
||||||
|
"enableTerminal": true,
|
||||||
|
"enableTunnel": false,
|
||||||
|
"enableFileManager": true,
|
||||||
|
"defaultPath": "/var/www"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}</code></pre>
|
||||||
|
|
||||||
|
<h2>Important Notes</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Maximum 100 hosts per import</li>
|
||||||
|
<li>File should contain a "hosts" array or be an array of host objects</li>
|
||||||
|
<li>All fields are copyable for easy reference</li>
|
||||||
|
<li>Use the Download Sample button to get a complete example file</li>
|
||||||
|
</ul>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
newWindow.document.close();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Format Guide
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-border mx-2"/>
|
||||||
|
|
||||||
|
<Button onClick={fetchHosts} variant="outline" size="sm">
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
id="json-import-input"
|
||||||
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
onChange={handleJsonImport}
|
||||||
|
style={{display: 'none'}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative mb-3">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
|
||||||
|
<Input
|
||||||
|
placeholder="Search hosts by name, username, IP, folder, tags..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1 min-h-0">
|
||||||
|
<div className="space-y-2 pb-20">
|
||||||
|
{Object.entries(hostsByFolder).map(([folder, folderHosts]) => (
|
||||||
|
<div key={folder} className="border rounded-md">
|
||||||
|
<Accordion type="multiple" defaultValue={Object.keys(hostsByFolder)}>
|
||||||
|
<AccordionItem value={folder} className="border-none">
|
||||||
|
<AccordionTrigger
|
||||||
|
className="px-2 py-1 bg-muted/20 border-b hover:no-underline rounded-t-md">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Folder className="h-4 w-4"/>
|
||||||
|
<span className="font-medium">{folder}</span>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{folderHosts.length}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="p-2">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||||
|
{folderHosts.map((host) => (
|
||||||
|
<div
|
||||||
|
key={host.id}
|
||||||
|
className="bg-[#222225] border border-input rounded cursor-pointer hover:shadow-md transition-shadow p-2"
|
||||||
|
onClick={() => handleEdit(host)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{host.pin && <Pin
|
||||||
|
className="h-3 w-3 text-yellow-500 flex-shrink-0"/>}
|
||||||
|
<h3 className="font-medium truncate text-sm">
|
||||||
|
{host.name || `${host.username}@${host.ip}`}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{host.ip}:{host.port}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{host.username}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 flex-shrink-0 ml-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleEdit(host);
|
||||||
|
}}
|
||||||
|
className="h-5 w-5 p-0"
|
||||||
|
>
|
||||||
|
<Edit className="h-3 w-3"/>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDelete(host.id, host.name || `${host.username}@${host.ip}`);
|
||||||
|
}}
|
||||||
|
className="h-5 w-5 p-0 text-red-500 hover:text-red-700"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3"/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{host.tags && host.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{host.tags.slice(0, 6).map((tag, index) => (
|
||||||
|
<Badge key={index} variant="outline"
|
||||||
|
className="text-xs px-1 py-0">
|
||||||
|
<Tag className="h-2 w-2 mr-0.5"/>
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{host.tags.length > 6 && (
|
||||||
|
<Badge variant="outline"
|
||||||
|
className="text-xs px-1 py-0">
|
||||||
|
+{host.tags.length - 6}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{host.enableTerminal && (
|
||||||
|
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||||
|
<Terminal className="h-2 w-2 mr-0.5"/>
|
||||||
|
Terminal
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{host.enableTunnel && (
|
||||||
|
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||||
|
<Network className="h-2 w-2 mr-0.5"/>
|
||||||
|
Tunnel
|
||||||
|
{host.tunnelConnections && host.tunnelConnections.length > 0 && (
|
||||||
|
<span
|
||||||
|
className="ml-0.5">({host.tunnelConnections.length})</span>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{host.enableFileManager && (
|
||||||
|
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||||
|
<FileEdit className="h-2 w-2 mr-0.5"/>
|
||||||
|
File Manager
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
289
src/ui/apps/Server/Server.tsx
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {useSidebar} from "@/components/ui/sidebar";
|
||||||
|
import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status";
|
||||||
|
import {Separator} from "@/components/ui/separator.tsx";
|
||||||
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
|
import {Progress} from "@/components/ui/progress"
|
||||||
|
import {Cpu, HardDrive, MemoryStick} from "lucide-react";
|
||||||
|
import {Tunnel} from "@/ui/apps/Tunnel/Tunnel.tsx";
|
||||||
|
import {getServerStatusById, getServerMetricsById, ServerMetrics} from "@/ui/main-axios.ts";
|
||||||
|
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
|
||||||
|
|
||||||
|
interface ServerProps {
|
||||||
|
hostConfig?: any;
|
||||||
|
title?: string;
|
||||||
|
isVisible?: boolean;
|
||||||
|
isTopbarOpen?: boolean;
|
||||||
|
embedded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Server({
|
||||||
|
hostConfig,
|
||||||
|
title,
|
||||||
|
isVisible = true,
|
||||||
|
isTopbarOpen = true,
|
||||||
|
embedded = false
|
||||||
|
}: ServerProps): React.ReactElement {
|
||||||
|
const {state: sidebarState} = useSidebar();
|
||||||
|
const {addTab, tabs} = useTabs() as any;
|
||||||
|
const [serverStatus, setServerStatus] = React.useState<'online' | 'offline'>('offline');
|
||||||
|
const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null);
|
||||||
|
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setCurrentHostConfig(hostConfig);
|
||||||
|
}, [hostConfig]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const fetchLatestHostConfig = async () => {
|
||||||
|
if (hostConfig?.id) {
|
||||||
|
try {
|
||||||
|
const {getSSHHosts} = await import('@/ui/main-axios.ts');
|
||||||
|
const hosts = await getSSHHosts();
|
||||||
|
const updatedHost = hosts.find(h => h.id === hostConfig.id);
|
||||||
|
if (updatedHost) {
|
||||||
|
setCurrentHostConfig(updatedHost);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchLatestHostConfig();
|
||||||
|
|
||||||
|
const handleHostsChanged = async () => {
|
||||||
|
if (hostConfig?.id) {
|
||||||
|
try {
|
||||||
|
const {getSSHHosts} = await import('@/ui/main-axios.ts');
|
||||||
|
const hosts = await getSSHHosts();
|
||||||
|
const updatedHost = hosts.find(h => h.id === hostConfig.id);
|
||||||
|
if (updatedHost) {
|
||||||
|
setCurrentHostConfig(updatedHost);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('ssh-hosts:changed', handleHostsChanged);
|
||||||
|
return () => window.removeEventListener('ssh-hosts:changed', handleHostsChanged);
|
||||||
|
}, [hostConfig?.id]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
let intervalId: number | undefined;
|
||||||
|
|
||||||
|
const fetchStatus = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getServerStatusById(currentHostConfig?.id);
|
||||||
|
if (!cancelled) {
|
||||||
|
setServerStatus(res?.status === 'online' ? 'online' : 'offline');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setServerStatus('offline');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchMetrics = async () => {
|
||||||
|
if (!currentHostConfig?.id) return;
|
||||||
|
try {
|
||||||
|
const data = await getServerMetricsById(currentHostConfig.id);
|
||||||
|
if (!cancelled) setMetrics(data);
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setMetrics(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (currentHostConfig?.id && isVisible) {
|
||||||
|
fetchStatus();
|
||||||
|
fetchMetrics();
|
||||||
|
// Only poll when component is visible to reduce unnecessary connections
|
||||||
|
intervalId = window.setInterval(() => {
|
||||||
|
if (isVisible) {
|
||||||
|
fetchStatus();
|
||||||
|
fetchMetrics();
|
||||||
|
}
|
||||||
|
}, 300_000); // 5 minutes instead of 10 seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (intervalId) window.clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, [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%'}
|
||||||
|
: {
|
||||||
|
opacity: isVisible ? 1 : 0,
|
||||||
|
marginLeft: leftMarginPx,
|
||||||
|
marginRight: 17,
|
||||||
|
marginTop: topMarginPx,
|
||||||
|
marginBottom: bottomMarginPx,
|
||||||
|
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const containerClass = embedded
|
||||||
|
? "h-full w-full text-white overflow-hidden bg-transparent"
|
||||||
|
: "bg-[#18181b] text-white rounded-lg border-2 border-[#303032] overflow-hidden";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={wrapperStyle} className={containerClass}>
|
||||||
|
<div className="h-full w-full flex flex-col">
|
||||||
|
|
||||||
|
{/* Top Header */}
|
||||||
|
<div className="flex items-center justify-between px-3 pt-2 pb-2">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<h1 className="font-bold text-lg">
|
||||||
|
{currentHostConfig?.folder} / {title}
|
||||||
|
</h1>
|
||||||
|
<Status status={serverStatus} className="!bg-transparent !p-0.75 flex-shrink-0">
|
||||||
|
<StatusIndicator/>
|
||||||
|
</Status>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={async () => {
|
||||||
|
if (currentHostConfig?.id) {
|
||||||
|
try {
|
||||||
|
const res = await getServerStatusById(currentHostConfig.id);
|
||||||
|
setServerStatus(res?.status === 'online' ? 'online' : 'offline');
|
||||||
|
const data = await getServerMetricsById(currentHostConfig.id);
|
||||||
|
setMetrics(data);
|
||||||
|
} catch {
|
||||||
|
setServerStatus('offline');
|
||||||
|
setMetrics(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Refresh status and metrics"
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
{currentHostConfig?.enableFileManager && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="font-semibold"
|
||||||
|
disabled={isFileManagerAlreadyOpen}
|
||||||
|
title={isFileManagerAlreadyOpen ? "File Manager already open for this host" : "Open File Manager"}
|
||||||
|
onClick={() => {
|
||||||
|
if (!currentHostConfig || isFileManagerAlreadyOpen) return;
|
||||||
|
const titleBase = currentHostConfig?.name && currentHostConfig.name.trim() !== ''
|
||||||
|
? currentHostConfig.name.trim()
|
||||||
|
: `${currentHostConfig.username}@${currentHostConfig.ip}`;
|
||||||
|
addTab({
|
||||||
|
type: 'file_manager',
|
||||||
|
title: titleBase,
|
||||||
|
hostConfig: currentHostConfig,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
File Manager
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator className="p-0.25 w-full"/>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="rounded-lg border-2 border-[#303032] m-3 bg-[#0e0e10] flex flex-row items-stretch">
|
||||||
|
{/* CPU */}
|
||||||
|
<div className="flex-1 min-w-0 px-2 py-2">
|
||||||
|
<h1 className="font-bold xt-lg flex flex-row gap-2 mb-2">
|
||||||
|
<Cpu/>
|
||||||
|
{(() => {
|
||||||
|
const pct = metrics?.cpu?.percent;
|
||||||
|
const cores = metrics?.cpu?.cores;
|
||||||
|
const la = metrics?.cpu?.load;
|
||||||
|
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
|
||||||
|
const coresText = (typeof cores === 'number') ? `${cores} CPU(s)` : 'N/A CPU(s)';
|
||||||
|
const laText = (la && la.length === 3)
|
||||||
|
? `Avg: ${la[0].toFixed(2)}, ${la[1].toFixed(2)}, ${la[2].toFixed(2)}`
|
||||||
|
: 'Avg: N/A';
|
||||||
|
return `CPU Usage - ${pctText} of ${coresText} (${laText})`;
|
||||||
|
})()}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<Progress value={typeof metrics?.cpu?.percent === 'number' ? metrics!.cpu!.percent! : 0}/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="p-0.5 self-stretch" orientation="vertical"/>
|
||||||
|
|
||||||
|
{/* Memory */}
|
||||||
|
<div className="flex-1 min-w-0 px-2 py-2">
|
||||||
|
<h1 className="font-bold xt-lg flex flex-row gap-2 mb-2">
|
||||||
|
<MemoryStick/>
|
||||||
|
{(() => {
|
||||||
|
const pct = metrics?.memory?.percent;
|
||||||
|
const used = metrics?.memory?.usedGiB;
|
||||||
|
const total = metrics?.memory?.totalGiB;
|
||||||
|
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
|
||||||
|
const usedText = (typeof used === 'number') ? `${used} GiB` : 'N/A';
|
||||||
|
const totalText = (typeof total === 'number') ? `${total} GiB` : 'N/A';
|
||||||
|
return `Memory Usage - ${pctText} (${usedText} of ${totalText})`;
|
||||||
|
})()}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<Progress value={typeof metrics?.memory?.percent === 'number' ? metrics!.memory!.percent! : 0}/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="p-0.5 self-stretch" orientation="vertical"/>
|
||||||
|
|
||||||
|
{/* Root Storage */}
|
||||||
|
<div className="flex-1 min-w-0 px-2 py-2">
|
||||||
|
<h1 className="font-bold xt-lg flex flex-row gap-2 mb-2">
|
||||||
|
<HardDrive/>
|
||||||
|
{(() => {
|
||||||
|
const pct = metrics?.disk?.percent;
|
||||||
|
const used = metrics?.disk?.usedHuman;
|
||||||
|
const total = metrics?.disk?.totalHuman;
|
||||||
|
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
|
||||||
|
const usedText = used ?? 'N/A';
|
||||||
|
const totalText = total ?? 'N/A';
|
||||||
|
return `Root Storage Space - ${pctText} (${usedText} of ${totalText})`;
|
||||||
|
})()}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<Progress value={typeof metrics?.disk?.percent === 'number' ? metrics!.disk!.percent! : 0}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SSH Tunnels */}
|
||||||
|
{(currentHostConfig?.tunnelConnections && currentHostConfig.tunnelConnections.length > 0) && (
|
||||||
|
<div
|
||||||
|
className="rounded-lg border-2 border-[#303032] m-3 bg-[#0e0e10] h-[360px] overflow-hidden flex flex-col min-h-0">
|
||||||
|
<Tunnel
|
||||||
|
filterHostKey={(currentHostConfig?.name && currentHostConfig.name.trim() !== '') ? currentHostConfig.name : `${currentHostConfig?.username}@${currentHostConfig?.ip}`}/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="px-4 pt-2 pb-2 text-sm text-gray-500">
|
||||||
|
Have ideas for what should come next for server management? Share them on{" "}
|
||||||
|
<a
|
||||||
|
href="https://github.com/LukeGus/Termix/issues/new"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-500 hover:underline"
|
||||||
|
>
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ interface SSHTerminalProps {
|
|||||||
splitScreen?: boolean;
|
splitScreen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||||
{hostConfig, isVisible, splitScreen = false},
|
{hostConfig, isVisible, splitScreen = false},
|
||||||
ref
|
ref
|
||||||
) {
|
) {
|
||||||
@@ -24,6 +24,42 @@ export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTermina
|
|||||||
const wasDisconnectedBySSH = useRef(false);
|
const wasDisconnectedBySSH = useRef(false);
|
||||||
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
|
const isVisibleRef = useRef<boolean>(false);
|
||||||
|
|
||||||
|
|
||||||
|
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
||||||
|
const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
||||||
|
const notifyTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const DEBOUNCE_MS = 140;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isVisibleRef.current = isVisible;
|
||||||
|
}, [isVisible]);
|
||||||
|
|
||||||
|
function hardRefresh() {
|
||||||
|
try {
|
||||||
|
if (terminal && typeof (terminal as any).refresh === 'function') {
|
||||||
|
(terminal as any).refresh(0, terminal.rows - 1);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleNotify(cols: number, rows: number) {
|
||||||
|
if (!(cols > 0 && rows > 0)) return;
|
||||||
|
pendingSizeRef.current = {cols, rows};
|
||||||
|
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
|
||||||
|
notifyTimerRef.current = setTimeout(() => {
|
||||||
|
const next = pendingSizeRef.current;
|
||||||
|
const last = lastSentSizeRef.current;
|
||||||
|
if (!next) return;
|
||||||
|
if (last && last.cols === next.cols && last.rows === next.rows) return;
|
||||||
|
if (webSocketRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
webSocketRef.current.send(JSON.stringify({type: 'resize', data: next}));
|
||||||
|
lastSentSizeRef.current = next;
|
||||||
|
}
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
disconnect: () => {
|
disconnect: () => {
|
||||||
@@ -35,13 +71,27 @@ export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTermina
|
|||||||
},
|
},
|
||||||
fit: () => {
|
fit: () => {
|
||||||
fitAddonRef.current?.fit();
|
fitAddonRef.current?.fit();
|
||||||
|
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||||
|
hardRefresh();
|
||||||
},
|
},
|
||||||
sendInput: (data: string) => {
|
sendInput: (data: string) => {
|
||||||
if (webSocketRef.current?.readyState === 1) {
|
if (webSocketRef.current?.readyState === 1) {
|
||||||
webSocketRef.current.send(JSON.stringify({type: 'input', data}));
|
webSocketRef.current.send(JSON.stringify({type: 'input', data}));
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
notifyResize: () => {
|
||||||
|
try {
|
||||||
|
const cols = terminal?.cols ?? undefined;
|
||||||
|
const rows = terminal?.rows ?? undefined;
|
||||||
|
if (typeof cols === 'number' && typeof rows === 'number') {
|
||||||
|
scheduleNotify(cols, rows);
|
||||||
|
hardRefresh();
|
||||||
}
|
}
|
||||||
}), []);
|
} catch (_) {
|
||||||
|
}
|
||||||
|
},
|
||||||
|
refresh: () => hardRefresh(),
|
||||||
|
}), [terminal]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.addEventListener('resize', handleWindowResize);
|
window.addEventListener('resize', handleWindowResize);
|
||||||
@@ -49,7 +99,10 @@ export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTermina
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function handleWindowResize() {
|
function handleWindowResize() {
|
||||||
|
if (!isVisibleRef.current) return;
|
||||||
fitAddonRef.current?.fit();
|
fitAddonRef.current?.fit();
|
||||||
|
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||||
|
hardRefresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCookie(name: string) {
|
function getCookie(name: string) {
|
||||||
@@ -63,6 +116,50 @@ export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTermina
|
|||||||
return getCookie("rightClickCopyPaste") === "true"
|
return getCookie("rightClickCopyPaste") === "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function setupWebSocketListeners(ws: WebSocket, cols: number, rows: number) {
|
||||||
|
ws.addEventListener('open', () => {
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({type: 'connectToHost', data: {cols, rows, hostConfig}}));
|
||||||
|
terminal.onData((data) => {
|
||||||
|
ws.send(JSON.stringify({type: 'input', data}));
|
||||||
|
});
|
||||||
|
|
||||||
|
pingIntervalRef.current = setInterval(() => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({type: 'ping'}));
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener('message', (event) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
if (msg.type === 'data') terminal.write(msg.data);
|
||||||
|
else if (msg.type === 'error') terminal.writeln(`\r\n[ERROR] ${msg.message}`);
|
||||||
|
else if (msg.type === 'connected') {
|
||||||
|
} else if (msg.type === 'disconnected') {
|
||||||
|
wasDisconnectedBySSH.current = true;
|
||||||
|
terminal.writeln(`\r\n[${msg.message || 'Disconnected'}]`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener('close', () => {
|
||||||
|
if (!wasDisconnectedBySSH.current) {
|
||||||
|
terminal.writeln('\r\n[Connection closed]');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener('error', () => {
|
||||||
|
terminal.writeln('\r\n[Connection error]');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function writeTextToClipboard(text: string): Promise<void> {
|
async function writeTextToClipboard(text: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
@@ -104,10 +201,7 @@ export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTermina
|
|||||||
scrollback: 10000,
|
scrollback: 10000,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: '"JetBrains Mono Nerd Font", "MesloLGS NF", "FiraCode Nerd Font", "Cascadia Code", "JetBrains Mono", Consolas, "Courier New", monospace',
|
fontFamily: '"JetBrains Mono Nerd Font", "MesloLGS NF", "FiraCode Nerd Font", "Cascadia Code", "JetBrains Mono", Consolas, "Courier New", monospace',
|
||||||
theme: {
|
theme: {background: '#18181b', foreground: '#f7f7f7'},
|
||||||
background: '#09090b',
|
|
||||||
foreground: '#f7f7f7',
|
|
||||||
},
|
|
||||||
allowTransparency: true,
|
allowTransparency: true,
|
||||||
convertEol: true,
|
convertEol: true,
|
||||||
windowsMode: false,
|
windowsMode: false,
|
||||||
@@ -145,37 +239,46 @@ export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTermina
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const pasteText = await readTextFromClipboard();
|
const pasteText = await readTextFromClipboard();
|
||||||
if (pasteText) {
|
if (pasteText) terminal.paste(pasteText);
|
||||||
terminal.paste(pasteText);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (element) {
|
element?.addEventListener('contextmenu', handleContextMenu);
|
||||||
element.addEventListener('contextmenu', handleContextMenu);
|
|
||||||
}
|
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||||
resizeTimeout.current = setTimeout(() => {
|
resizeTimeout.current = setTimeout(() => {
|
||||||
|
if (!isVisibleRef.current) return;
|
||||||
fitAddonRef.current?.fit();
|
fitAddonRef.current?.fit();
|
||||||
const cols = terminal.cols;
|
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||||
const rows = terminal.rows;
|
hardRefresh();
|
||||||
if (webSocketRef.current?.readyState === WebSocket.OPEN) {
|
|
||||||
webSocketRef.current.send(JSON.stringify({type: 'resize', data: {cols, rows}}));
|
|
||||||
}
|
|
||||||
}, 100);
|
}, 100);
|
||||||
});
|
});
|
||||||
|
|
||||||
resizeObserver.observe(xtermRef.current);
|
resizeObserver.observe(xtermRef.current);
|
||||||
|
|
||||||
|
const readyFonts = (document as any).fonts?.ready instanceof Promise ? (document as any).fonts.ready : Promise.resolve();
|
||||||
|
readyFonts.then(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
|
setTimeout(() => {
|
||||||
|
fitAddon.fit();
|
||||||
|
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||||
|
hardRefresh();
|
||||||
setVisible(true);
|
setVisible(true);
|
||||||
|
if (terminal && !splitScreen) {
|
||||||
|
terminal.focus();
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
|
||||||
const cols = terminal.cols;
|
const cols = terminal.cols;
|
||||||
const rows = terminal.rows;
|
const rows = terminal.rows;
|
||||||
const wsUrl = window.location.hostname === 'localhost'
|
|
||||||
|
const isDev = process.env.NODE_ENV === 'development' &&
|
||||||
|
(window.location.port === '3000' || window.location.port === '5173' || window.location.port === '');
|
||||||
|
|
||||||
|
const wsUrl = isDev
|
||||||
? 'ws://localhost:8082'
|
? 'ws://localhost:8082'
|
||||||
: `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`;
|
: `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`;
|
||||||
|
|
||||||
@@ -183,52 +286,14 @@ export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTermina
|
|||||||
webSocketRef.current = ws;
|
webSocketRef.current = ws;
|
||||||
wasDisconnectedBySSH.current = false;
|
wasDisconnectedBySSH.current = false;
|
||||||
|
|
||||||
ws.addEventListener('open', () => {
|
setupWebSocketListeners(ws, cols, rows);
|
||||||
ws.send(JSON.stringify({type: 'connectToHost', data: {cols, rows, hostConfig}}));
|
|
||||||
terminal.onData((data) => {
|
|
||||||
ws.send(JSON.stringify({type: 'input', data}));
|
|
||||||
});
|
|
||||||
|
|
||||||
pingIntervalRef.current = setInterval(() => {
|
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
|
||||||
ws.send(JSON.stringify({type: 'ping'}));
|
|
||||||
}
|
|
||||||
}, 30000);
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.addEventListener('message', (event) => {
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(event.data);
|
|
||||||
if (msg.type === 'data') {
|
|
||||||
terminal.write(msg.data);
|
|
||||||
} else if (msg.type === 'error') {
|
|
||||||
terminal.writeln(`\r\n[ERROR] ${msg.message}`);
|
|
||||||
} else if (msg.type === 'connected') {
|
|
||||||
} else if (msg.type === 'disconnected') {
|
|
||||||
wasDisconnectedBySSH.current = true;
|
|
||||||
terminal.writeln(`\r\n[${msg.message || 'Disconnected'}]`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error parsing WebSocket message:', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.addEventListener('close', () => {
|
|
||||||
if (!wasDisconnectedBySSH.current) {
|
|
||||||
terminal.writeln('\r\n[Connection closed]');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.addEventListener('error', () => {
|
|
||||||
terminal.writeln('\r\n[Connection error]');
|
|
||||||
});
|
|
||||||
}, 300);
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
if (element) {
|
element?.removeEventListener('contextmenu', handleContextMenu);
|
||||||
element.removeEventListener('contextmenu', handleContextMenu);
|
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
|
||||||
}
|
|
||||||
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||||
if (pingIntervalRef.current) {
|
if (pingIntervalRef.current) {
|
||||||
clearInterval(pingIntervalRef.current);
|
clearInterval(pingIntervalRef.current);
|
||||||
@@ -240,22 +305,44 @@ export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTermina
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isVisible && fitAddonRef.current) {
|
if (isVisible && fitAddonRef.current) {
|
||||||
fitAddonRef.current.fit();
|
setTimeout(() => {
|
||||||
|
fitAddonRef.current?.fit();
|
||||||
|
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||||
|
hardRefresh();
|
||||||
|
if (terminal && !splitScreen) {
|
||||||
|
terminal.focus();
|
||||||
}
|
}
|
||||||
}, [isVisible]);
|
}, 0);
|
||||||
|
|
||||||
|
if (terminal && !splitScreen) {
|
||||||
|
setTimeout(() => {
|
||||||
|
terminal.focus();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isVisible, splitScreen, terminal]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fitAddonRef.current) return;
|
||||||
|
setTimeout(() => {
|
||||||
|
fitAddonRef.current?.fit();
|
||||||
|
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||||
|
hardRefresh();
|
||||||
|
if (terminal && !splitScreen && isVisible) {
|
||||||
|
terminal.focus();
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}, [splitScreen, isVisible, terminal]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={xtermRef}
|
ref={xtermRef}
|
||||||
style={{
|
className="h-full w-full m-1"
|
||||||
position: 'absolute',
|
style={{opacity: visible && isVisible ? 1 : 0, overflow: 'hidden'}}
|
||||||
top: 0,
|
onClick={() => {
|
||||||
left: 0,
|
if (terminal && !splitScreen) {
|
||||||
right: 0,
|
terminal.focus();
|
||||||
bottom: 0,
|
}
|
||||||
marginLeft: 2,
|
|
||||||
opacity: visible && isVisible ? 1 : 0,
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -1,11 +1,6 @@
|
|||||||
import React, {useState, useEffect, useCallback} from "react";
|
import React, {useState, useEffect, useCallback} from "react";
|
||||||
import {SSHTunnelSidebar} from "@/apps/SSH/Tunnel/SSHTunnelSidebar.tsx";
|
import {TunnelViewer} from "@/ui/apps/Tunnel/TunnelViewer.tsx";
|
||||||
import {SSHTunnelViewer} from "@/apps/SSH/Tunnel/SSHTunnelViewer.tsx";
|
import {getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel} from "@/ui/main-axios.ts";
|
||||||
import {getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel} from "@/apps/SSH/ssh-axios";
|
|
||||||
|
|
||||||
interface ConfigEditorProps {
|
|
||||||
onSelectView: (view: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TunnelConnection {
|
interface TunnelConnection {
|
||||||
sourcePort: number;
|
sourcePort: number;
|
||||||
@@ -32,7 +27,7 @@ interface SSHHost {
|
|||||||
keyType?: string;
|
keyType?: string;
|
||||||
enableTerminal: boolean;
|
enableTerminal: boolean;
|
||||||
enableTunnel: boolean;
|
enableTunnel: boolean;
|
||||||
enableConfigEditor: boolean;
|
enableFileManager: boolean;
|
||||||
defaultPath: string;
|
defaultPath: string;
|
||||||
tunnelConnections: TunnelConnection[];
|
tunnelConnections: TunnelConnection[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -49,31 +44,89 @@ interface TunnelStatus {
|
|||||||
retryExhausted?: boolean;
|
retryExhausted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SSHTunnel({onSelectView}: ConfigEditorProps): React.ReactElement {
|
interface SSHTunnelProps {
|
||||||
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
filterHostKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Tunnel({filterHostKey}: SSHTunnelProps): React.ReactElement {
|
||||||
|
const [allHosts, setAllHosts] = useState<SSHHost[]>([]);
|
||||||
|
const [visibleHosts, setVisibleHosts] = useState<SSHHost[]>([]);
|
||||||
const [tunnelStatuses, setTunnelStatuses] = useState<Record<string, TunnelStatus>>({});
|
const [tunnelStatuses, setTunnelStatuses] = useState<Record<string, TunnelStatus>>({});
|
||||||
const [tunnelActions, setTunnelActions] = useState<Record<string, boolean>>({});
|
const [tunnelActions, setTunnelActions] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
const fetchHosts = useCallback(async () => {
|
const prevVisibleHostRef = React.useRef<SSHHost | null>(null);
|
||||||
try {
|
|
||||||
const hostsData = await getSSHHosts();
|
const haveTunnelConnectionsChanged = (a: TunnelConnection[] = [], b: TunnelConnection[] = []): boolean => {
|
||||||
setHosts(hostsData);
|
if (a.length !== b.length) return true;
|
||||||
} catch (err) {
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
const x = a[i];
|
||||||
|
const y = b[i];
|
||||||
|
if (
|
||||||
|
x.sourcePort !== y.sourcePort ||
|
||||||
|
x.endpointPort !== y.endpointPort ||
|
||||||
|
x.endpointHost !== y.endpointHost ||
|
||||||
|
x.maxRetries !== y.maxRetries ||
|
||||||
|
x.retryInterval !== y.retryInterval ||
|
||||||
|
x.autoStart !== y.autoStart
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}, []);
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchHosts = useCallback(async () => {
|
||||||
|
const hostsData = await getSSHHosts();
|
||||||
|
setAllHosts(hostsData);
|
||||||
|
const nextVisible = filterHostKey
|
||||||
|
? hostsData.filter(h => {
|
||||||
|
const key = (h.name && h.name.trim() !== '') ? h.name : `${h.username}@${h.ip}`;
|
||||||
|
return key === filterHostKey;
|
||||||
|
})
|
||||||
|
: hostsData;
|
||||||
|
|
||||||
|
const prev = prevVisibleHostRef.current;
|
||||||
|
const curr = nextVisible[0] ?? null;
|
||||||
|
let changed = false;
|
||||||
|
if (!prev && curr) changed = true;
|
||||||
|
else if (prev && !curr) changed = true;
|
||||||
|
else if (prev && curr) {
|
||||||
|
if (
|
||||||
|
prev.id !== curr.id ||
|
||||||
|
prev.name !== curr.name ||
|
||||||
|
prev.ip !== curr.ip ||
|
||||||
|
prev.port !== curr.port ||
|
||||||
|
prev.username !== curr.username ||
|
||||||
|
haveTunnelConnectionsChanged(prev.tunnelConnections, curr.tunnelConnections)
|
||||||
|
) {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
setVisibleHosts(nextVisible);
|
||||||
|
prevVisibleHostRef.current = curr;
|
||||||
|
}
|
||||||
|
}, [filterHostKey]);
|
||||||
|
|
||||||
const fetchTunnelStatuses = useCallback(async () => {
|
const fetchTunnelStatuses = useCallback(async () => {
|
||||||
try {
|
|
||||||
const statusData = await getTunnelStatuses();
|
const statusData = await getTunnelStatuses();
|
||||||
setTunnelStatuses(statusData);
|
setTunnelStatuses(statusData);
|
||||||
} catch (err) {
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchHosts();
|
fetchHosts();
|
||||||
const interval = setInterval(fetchHosts, 10000);
|
const interval = setInterval(fetchHosts, 5000);
|
||||||
return () => clearInterval(interval);
|
|
||||||
|
const handleHostsChanged = () => {
|
||||||
|
fetchHosts();
|
||||||
|
};
|
||||||
|
window.addEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
window.removeEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
|
||||||
|
};
|
||||||
}, [fetchHosts]);
|
}, [fetchHosts]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -90,7 +143,7 @@ export function SSHTunnel({onSelectView}: ConfigEditorProps): React.ReactElement
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (action === 'connect') {
|
if (action === 'connect') {
|
||||||
const endpointHost = hosts.find(h =>
|
const endpointHost = allHosts.find(h =>
|
||||||
h.name === tunnel.endpointHost ||
|
h.name === tunnel.endpointHost ||
|
||||||
`${h.username}@${h.ip}` === tunnel.endpointHost
|
`${h.username}@${h.ip}` === tunnel.endpointHost
|
||||||
);
|
);
|
||||||
@@ -141,20 +194,11 @@ export function SSHTunnel({onSelectView}: ConfigEditorProps): React.ReactElement
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-full">
|
<TunnelViewer
|
||||||
<div className="w-64 flex-shrink-0">
|
hosts={visibleHosts}
|
||||||
<SSHTunnelSidebar
|
|
||||||
onSelectView={onSelectView}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 overflow-auto">
|
|
||||||
<SSHTunnelViewer
|
|
||||||
hosts={hosts}
|
|
||||||
tunnelStatuses={tunnelStatuses}
|
tunnelStatuses={tunnelStatuses}
|
||||||
tunnelActions={tunnelActions}
|
tunnelActions={tunnelActions}
|
||||||
onTunnelAction={handleTunnelAction}
|
onTunnelAction={handleTunnelAction}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,7 @@ interface SSHHost {
|
|||||||
authType: string;
|
authType: string;
|
||||||
enableTerminal: boolean;
|
enableTerminal: boolean;
|
||||||
enableTunnel: boolean;
|
enableTunnel: boolean;
|
||||||
enableConfigEditor: boolean;
|
enableFileManager: boolean;
|
||||||
defaultPath: string;
|
defaultPath: string;
|
||||||
tunnelConnections: TunnelConnection[];
|
tunnelConnections: TunnelConnection[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -75,13 +75,17 @@ interface SSHTunnelObjectProps {
|
|||||||
tunnelStatuses: Record<string, TunnelStatus>;
|
tunnelStatuses: Record<string, TunnelStatus>;
|
||||||
tunnelActions: Record<string, boolean>;
|
tunnelActions: Record<string, boolean>;
|
||||||
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>;
|
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>;
|
||||||
|
compact?: boolean;
|
||||||
|
bare?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SSHTunnelObject({
|
export function TunnelObject({
|
||||||
host,
|
host,
|
||||||
tunnelStatuses,
|
tunnelStatuses,
|
||||||
tunnelActions,
|
tunnelActions,
|
||||||
onTunnelAction
|
onTunnelAction,
|
||||||
|
compact = false,
|
||||||
|
bare = false
|
||||||
}: SSHTunnelObjectProps): React.ReactElement {
|
}: SSHTunnelObjectProps): React.ReactElement {
|
||||||
|
|
||||||
const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => {
|
const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => {
|
||||||
@@ -161,10 +165,150 @@ export function SSHTunnelObject({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (bare) {
|
||||||
|
return (
|
||||||
|
<div className="w-full min-w-0">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{host.tunnelConnections.map((tunnel, tunnelIndex) => {
|
||||||
|
const status = getTunnelStatus(tunnelIndex);
|
||||||
|
const statusDisplay = getTunnelStatusDisplay(status);
|
||||||
|
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
|
||||||
|
const isActionLoading = tunnelActions[tunnelName];
|
||||||
|
const statusValue = status?.status?.toUpperCase() || 'DISCONNECTED';
|
||||||
|
const isConnected = statusValue === 'CONNECTED';
|
||||||
|
const isConnecting = statusValue === 'CONNECTING';
|
||||||
|
const isDisconnecting = statusValue === 'DISCONNECTING';
|
||||||
|
const isRetrying = statusValue === 'RETRYING';
|
||||||
|
const isWaiting = statusValue === 'WAITING';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={tunnelIndex}
|
||||||
|
className={`border rounded-lg p-3 min-w-0 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex items-start gap-2 flex-1 min-w-0">
|
||||||
|
<span className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}>
|
||||||
|
{statusDisplay.icon}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium break-words">
|
||||||
|
Port {tunnel.sourcePort} → {tunnel.endpointHost}:{tunnel.endpointPort}
|
||||||
|
</div>
|
||||||
|
<div className={`text-xs ${statusDisplay.color} font-medium`}>
|
||||||
|
{statusDisplay.text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-1 flex-shrink-0 min-w-[120px]">
|
||||||
|
{!isActionLoading ? (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{isConnected ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onTunnelAction('disconnect', host, tunnelIndex)}
|
||||||
|
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
|
||||||
|
>
|
||||||
|
<Square className="h-3 w-3 mr-1"/>
|
||||||
|
Disconnect
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : isRetrying || isWaiting ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onTunnelAction('cancel', host, tunnelIndex)}
|
||||||
|
className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3 mr-1"/>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onTunnelAction('connect', host, tunnelIndex)}
|
||||||
|
disabled={isConnecting || isDisconnecting}
|
||||||
|
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
|
||||||
|
>
|
||||||
|
<Play className="h-3 w-3 mr-1"/>
|
||||||
|
Connect
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled
|
||||||
|
className="h-7 px-2 text-muted-foreground border-border text-xs"
|
||||||
|
>
|
||||||
|
<Loader2 className="h-3 w-3 mr-1 animate-spin"/>
|
||||||
|
{isConnected ? 'Disconnecting...' : isRetrying || isWaiting ? 'Canceling...' : 'Connecting...'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && (
|
||||||
|
<div
|
||||||
|
className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
|
||||||
|
<div className="font-medium mb-1">Error:</div>
|
||||||
|
{status.reason}
|
||||||
|
{status.reason && status.reason.includes('Max retries exhausted') && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="mt-2 pt-2 border-t border-red-500/20 dark:border-red-400/20">
|
||||||
|
Check your Docker logs for the error reason, join the <a
|
||||||
|
href="https://discord.com/invite/jVQGdvHDrf" target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline text-blue-600 dark:text-blue-400">Discord</a> or
|
||||||
|
create a <a
|
||||||
|
href="https://github.com/LukeGus/Termix/issues/new"
|
||||||
|
target="_blank" rel="noopener noreferrer"
|
||||||
|
className="underline text-blue-600 dark:text-blue-400">GitHub
|
||||||
|
issue</a> for help.
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(statusValue === 'RETRYING' || statusValue === 'WAITING') && status?.retryCount && status?.maxRetries && (
|
||||||
|
<div
|
||||||
|
className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
|
||||||
|
<div className="font-medium mb-1">
|
||||||
|
{statusValue === 'WAITING' ? 'Waiting for retry' : 'Retrying connection'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Attempt {status.retryCount} of {status.maxRetries}
|
||||||
|
{status.nextRetryIn && (
|
||||||
|
<span> • Next retry in {status.nextRetryIn} seconds</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-4 text-muted-foreground">
|
||||||
|
<Network className="h-8 w-8 mx-auto mb-2 opacity-50"/>
|
||||||
|
<p className="text-sm">No tunnel connections configured</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="w-full bg-card border-border shadow-sm hover:shadow-md transition-shadow relative group p-0">
|
<Card className="w-full bg-card border-border shadow-sm hover:shadow-md transition-shadow relative group p-0">
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
{/* Host Header */}
|
{!compact && (
|
||||||
<div className="flex items-center justify-between gap-2 mb-3">
|
<div className="flex items-center justify-between gap-2 mb-3">
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
{host.pin && <Pin className="h-4 w-4 text-yellow-500 flex-shrink-0"/>}
|
{host.pin && <Pin className="h-4 w-4 text-yellow-500 flex-shrink-0"/>}
|
||||||
@@ -178,9 +322,9 @@ export function SSHTunnelObject({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Tags */}
|
{!compact && host.tags && host.tags.length > 0 && (
|
||||||
{host.tags && host.tags.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-1 mb-3">
|
<div className="flex flex-wrap gap-1 mb-3">
|
||||||
{host.tags.slice(0, 3).map((tag, index) => (
|
{host.tags.slice(0, 3).map((tag, index) => (
|
||||||
<Badge key={index} variant="secondary" className="text-xs px-1 py-0">
|
<Badge key={index} variant="secondary" className="text-xs px-1 py-0">
|
||||||
@@ -196,14 +340,15 @@ export function SSHTunnelObject({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Separator className="mb-3"/>
|
{!compact && <Separator className="mb-3"/>}
|
||||||
|
|
||||||
{/* Tunnel Connections */}
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
{!compact && (
|
||||||
<h4 className="text-sm font-medium text-card-foreground flex items-center gap-2">
|
<h4 className="text-sm font-medium text-card-foreground flex items-center gap-2">
|
||||||
<Network className="h-4 w-4"/>
|
<Network className="h-4 w-4"/>
|
||||||
Tunnel Connections ({host.tunnelConnections.length})
|
Tunnel Connections ({host.tunnelConnections.length})
|
||||||
</h4>
|
</h4>
|
||||||
|
)}
|
||||||
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
|
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{host.tunnelConnections.map((tunnel, tunnelIndex) => {
|
{host.tunnelConnections.map((tunnel, tunnelIndex) => {
|
||||||
@@ -221,7 +366,6 @@ export function SSHTunnelObject({
|
|||||||
return (
|
return (
|
||||||
<div key={tunnelIndex}
|
<div key={tunnelIndex}
|
||||||
className={`border rounded-lg p-3 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}>
|
className={`border rounded-lg p-3 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}>
|
||||||
{/* Tunnel Header */}
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="flex items-start gap-2 flex-1 min-w-0">
|
<div className="flex items-start gap-2 flex-1 min-w-0">
|
||||||
<span className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}>
|
<span className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}>
|
||||||
@@ -237,13 +381,6 @@ export function SSHTunnelObject({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
{tunnel.autoStart && (
|
|
||||||
<Badge variant="outline" className="text-xs px-2 py-1">
|
|
||||||
<Zap className="h-3 w-3 mr-1"/>
|
|
||||||
Auto
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{/* Action Buttons */}
|
|
||||||
{!isActionLoading && (
|
{!isActionLoading && (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{isConnected ? (
|
{isConnected ? (
|
||||||
@@ -296,7 +433,6 @@ export function SSHTunnelObject({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error/Status Reason */}
|
|
||||||
{(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && (
|
{(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && (
|
||||||
<div
|
<div
|
||||||
className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
|
className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
|
||||||
@@ -321,7 +457,6 @@ export function SSHTunnelObject({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Retry Info */}
|
|
||||||
{(statusValue === 'RETRYING' || statusValue === 'WAITING') && status?.retryCount && status?.maxRetries && (
|
{(statusValue === 'RETRYING' || statusValue === 'WAITING') && status?.retryCount && status?.maxRetries && (
|
||||||
<div
|
<div
|
||||||
className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
|
className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
|
||||||
92
src/ui/apps/Tunnel/TunnelViewer.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {TunnelObject} from "./TunnelObject.tsx";
|
||||||
|
|
||||||
|
interface TunnelConnection {
|
||||||
|
sourcePort: number;
|
||||||
|
endpointPort: number;
|
||||||
|
endpointHost: string;
|
||||||
|
maxRetries: number;
|
||||||
|
retryInterval: number;
|
||||||
|
autoStart: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SSHHost {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
folder: string;
|
||||||
|
tags: string[];
|
||||||
|
pin: boolean;
|
||||||
|
authType: string;
|
||||||
|
enableTerminal: boolean;
|
||||||
|
enableTunnel: boolean;
|
||||||
|
enableFileManager: boolean;
|
||||||
|
defaultPath: string;
|
||||||
|
tunnelConnections: TunnelConnection[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TunnelStatus {
|
||||||
|
status: string;
|
||||||
|
reason?: string;
|
||||||
|
errorType?: string;
|
||||||
|
retryCount?: number;
|
||||||
|
maxRetries?: number;
|
||||||
|
nextRetryIn?: number;
|
||||||
|
retryExhausted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SSHTunnelViewerProps {
|
||||||
|
hosts: SSHHost[];
|
||||||
|
tunnelStatuses: Record<string, TunnelStatus>;
|
||||||
|
tunnelActions: Record<string, boolean>;
|
||||||
|
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TunnelViewer({
|
||||||
|
hosts = [],
|
||||||
|
tunnelStatuses = {},
|
||||||
|
tunnelActions = {},
|
||||||
|
onTunnelAction
|
||||||
|
}: SSHTunnelViewerProps): React.ReactElement {
|
||||||
|
const activeHost: SSHHost | undefined = Array.isArray(hosts) && hosts.length > 0 ? hosts[0] : undefined;
|
||||||
|
|
||||||
|
if (!activeHost || !activeHost.tunnelConnections || activeHost.tunnelConnections.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex flex-col items-center justify-center text-center p-3">
|
||||||
|
<h3 className="text-lg font-semibold text-foreground mb-2">No SSH Tunnels</h3>
|
||||||
|
<p className="text-muted-foreground max-w-md">
|
||||||
|
Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel
|
||||||
|
connections.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex flex-col overflow-hidden p-3 min-h-0">
|
||||||
|
<div className="w-full flex-shrink-0 mb-2">
|
||||||
|
<h1 className="text-xl font-semibold text-foreground">SSH Tunnels</h1>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto pr-1">
|
||||||
|
<div
|
||||||
|
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||||
|
{activeHost.tunnelConnections.map((t, idx) => (
|
||||||
|
<TunnelObject
|
||||||
|
key={`tunnel-${activeHost.id}-${t.endpointHost}-${t.sourcePort}-${t.endpointPort}`}
|
||||||
|
host={{...activeHost, tunnelConnections: [activeHost.tunnelConnections[idx]]}}
|
||||||
|
tunnelStatuses={tunnelStatuses}
|
||||||
|
tunnelActions={tunnelActions}
|
||||||
|
onTunnelAction={(action, _host, _index) => onTunnelAction(action, activeHost, idx)}
|
||||||
|
compact
|
||||||
|
bare
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
953
src/ui/main-axios.ts
Normal file
@@ -0,0 +1,953 @@
|
|||||||
|
import axios, { AxiosError, type AxiosInstance } from 'axios';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPES & INTERFACES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface SSHHostData {
|
||||||
|
name?: string;
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
folder?: string;
|
||||||
|
tags?: string[];
|
||||||
|
pin?: boolean;
|
||||||
|
authType: 'password' | 'key';
|
||||||
|
password?: string;
|
||||||
|
key?: File | null;
|
||||||
|
keyPassword?: string;
|
||||||
|
keyType?: string;
|
||||||
|
enableTerminal?: boolean;
|
||||||
|
enableTunnel?: boolean;
|
||||||
|
enableFileManager?: boolean;
|
||||||
|
defaultPath?: string;
|
||||||
|
tunnelConnections?: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SSHHost {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
folder: string;
|
||||||
|
tags: string[];
|
||||||
|
pin: boolean;
|
||||||
|
authType: string;
|
||||||
|
password?: string;
|
||||||
|
key?: string;
|
||||||
|
keyPassword?: string;
|
||||||
|
keyType?: string;
|
||||||
|
enableTerminal: boolean;
|
||||||
|
enableTunnel: boolean;
|
||||||
|
enableFileManager: boolean;
|
||||||
|
defaultPath: string;
|
||||||
|
tunnelConnections: any[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TunnelConfig {
|
||||||
|
name: string;
|
||||||
|
hostName: string;
|
||||||
|
sourceIP: string;
|
||||||
|
sourceSSHPort: number;
|
||||||
|
sourceUsername: string;
|
||||||
|
sourcePassword?: string;
|
||||||
|
sourceAuthMethod: string;
|
||||||
|
sourceSSHKey?: string;
|
||||||
|
sourceKeyPassword?: string;
|
||||||
|
sourceKeyType?: string;
|
||||||
|
endpointIP: string;
|
||||||
|
endpointSSHPort: number;
|
||||||
|
endpointUsername: string;
|
||||||
|
endpointPassword?: string;
|
||||||
|
endpointAuthMethod: string;
|
||||||
|
endpointSSHKey?: string;
|
||||||
|
endpointKeyPassword?: string;
|
||||||
|
endpointKeyType?: string;
|
||||||
|
sourcePort: number;
|
||||||
|
endpointPort: number;
|
||||||
|
maxRetries: number;
|
||||||
|
retryInterval: number;
|
||||||
|
autoStart: boolean;
|
||||||
|
isPinned: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TunnelStatus {
|
||||||
|
status: string;
|
||||||
|
reason?: string;
|
||||||
|
errorType?: string;
|
||||||
|
retryCount?: number;
|
||||||
|
maxRetries?: number;
|
||||||
|
nextRetryIn?: number;
|
||||||
|
retryExhausted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileManagerFile {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
type?: 'file' | 'directory';
|
||||||
|
isSSH?: boolean;
|
||||||
|
sshSessionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileManagerShortcut {
|
||||||
|
name: string;
|
||||||
|
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: CpuMetrics;
|
||||||
|
memory: MemoryMetrics;
|
||||||
|
disk: DiskMetrics;
|
||||||
|
lastChecked: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AuthResponse {
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserInfo {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
is_admin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserCount {
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OIDCAuthorize {
|
||||||
|
auth_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 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}`;
|
||||||
|
const parts = value.split(`; ${name}=`);
|
||||||
|
if (parts.length === 2) return parts.pop()?.split(';').shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createApiInstance(baseURL: string): AxiosInstance {
|
||||||
|
const instance = axios.create({
|
||||||
|
baseURL,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
instance.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);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleApiError(error: unknown, operation: string): never {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
const status = error.response?.status;
|
||||||
|
const message = error.response?.data?.error || error.message;
|
||||||
|
|
||||||
|
if (status === 401) {
|
||||||
|
throw new ApiError('Authentication required', 401);
|
||||||
|
} else if (status === 403) {
|
||||||
|
throw new ApiError('Access denied', 403);
|
||||||
|
} else if (status === 404) {
|
||||||
|
throw new ApiError('Resource not found', 404);
|
||||||
|
} else if (status && status >= 500) {
|
||||||
|
throw new ApiError('Server error occurred', status);
|
||||||
|
} else {
|
||||||
|
throw new ApiError(message || `Failed to ${operation}`, status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ApiError(`Unexpected error during ${operation}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SSH HOST MANAGEMENT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function getSSHHosts(): Promise<SSHHost[]> {
|
||||||
|
try {
|
||||||
|
const response = await sshHostApi.get('/db/host');
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'fetch SSH hosts');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
|
||||||
|
try {
|
||||||
|
const submitData = {
|
||||||
|
name: hostData.name || '',
|
||||||
|
ip: hostData.ip,
|
||||||
|
port: parseInt(hostData.port.toString()) || 22,
|
||||||
|
username: hostData.username,
|
||||||
|
folder: hostData.folder || '',
|
||||||
|
tags: hostData.tags || [],
|
||||||
|
pin: hostData.pin || false,
|
||||||
|
authMethod: hostData.authType,
|
||||||
|
password: hostData.authType === 'password' ? hostData.password : '',
|
||||||
|
key: hostData.authType === 'key' ? hostData.key : null,
|
||||||
|
keyPassword: hostData.authType === 'key' ? hostData.keyPassword : '',
|
||||||
|
keyType: hostData.authType === 'key' ? hostData.keyType : '',
|
||||||
|
enableTerminal: hostData.enableTerminal !== false,
|
||||||
|
enableTunnel: hostData.enableTunnel !== false,
|
||||||
|
enableFileManager: hostData.enableFileManager !== false,
|
||||||
|
defaultPath: hostData.defaultPath || '/',
|
||||||
|
tunnelConnections: hostData.tunnelConnections || [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!submitData.enableTunnel) {
|
||||||
|
submitData.tunnelConnections = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!submitData.enableFileManager) {
|
||||||
|
submitData.defaultPath = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hostData.authType === 'key' && hostData.key instanceof File) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('key', hostData.key);
|
||||||
|
|
||||||
|
const dataWithoutFile = { ...submitData };
|
||||||
|
delete dataWithoutFile.key;
|
||||||
|
formData.append('data', JSON.stringify(dataWithoutFile));
|
||||||
|
|
||||||
|
const response = await sshHostApi.post('/db/host', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} else {
|
||||||
|
const response = await sshHostApi.post('/db/host', submitData);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'create SSH host');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSSHHost(hostId: number, hostData: SSHHostData): Promise<SSHHost> {
|
||||||
|
try {
|
||||||
|
const submitData = {
|
||||||
|
name: hostData.name || '',
|
||||||
|
ip: hostData.ip,
|
||||||
|
port: parseInt(hostData.port.toString()) || 22,
|
||||||
|
username: hostData.username,
|
||||||
|
folder: hostData.folder || '',
|
||||||
|
tags: hostData.tags || [],
|
||||||
|
pin: hostData.pin || false,
|
||||||
|
authMethod: hostData.authType,
|
||||||
|
password: hostData.authType === 'password' ? hostData.password : '',
|
||||||
|
key: hostData.authType === 'key' ? hostData.key : null,
|
||||||
|
keyPassword: hostData.authType === 'key' ? hostData.keyPassword : '',
|
||||||
|
keyType: hostData.authType === 'key' ? hostData.keyType : '',
|
||||||
|
enableTerminal: hostData.enableTerminal !== false,
|
||||||
|
enableTunnel: hostData.enableTunnel !== false,
|
||||||
|
enableFileManager: hostData.enableFileManager !== false,
|
||||||
|
defaultPath: hostData.defaultPath || '/',
|
||||||
|
tunnelConnections: hostData.tunnelConnections || [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!submitData.enableTunnel) {
|
||||||
|
submitData.tunnelConnections = [];
|
||||||
|
}
|
||||||
|
if (!submitData.enableFileManager) {
|
||||||
|
submitData.defaultPath = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hostData.authType === 'key' && hostData.key instanceof File) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('key', hostData.key);
|
||||||
|
|
||||||
|
const dataWithoutFile = { ...submitData };
|
||||||
|
delete dataWithoutFile.key;
|
||||||
|
formData.append('data', JSON.stringify(dataWithoutFile));
|
||||||
|
|
||||||
|
const response = await sshHostApi.put(`/db/host/${hostId}`, formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} else {
|
||||||
|
const response = await sshHostApi.put(`/db/host/${hostId}`, submitData);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'update SSH host');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bulkImportSSHHosts(hosts: SSHHostData[]): Promise<{
|
||||||
|
message: string;
|
||||||
|
success: number;
|
||||||
|
failed: number;
|
||||||
|
errors: string[];
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await sshHostApi.post('/bulk-import', { hosts });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'bulk import SSH hosts');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSSHHost(hostId: number): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await sshHostApi.delete(`/db/host/${hostId}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'delete SSH host');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSSHHostById(hostId: number): Promise<SSHHost> {
|
||||||
|
try {
|
||||||
|
const response = await sshHostApi.get(`/db/host/${hostId}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'fetch SSH host');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TUNNEL MANAGEMENT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function getTunnelStatuses(): Promise<Record<string, TunnelStatus>> {
|
||||||
|
try {
|
||||||
|
const response = await tunnelApi.get('/tunnel/status');
|
||||||
|
return response.data || {};
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'fetch tunnel statuses');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTunnelStatusByName(tunnelName: string): Promise<TunnelStatus | undefined> {
|
||||||
|
const statuses = await getTunnelStatuses();
|
||||||
|
return statuses[tunnelName];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function connectTunnel(tunnelConfig: TunnelConfig): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await tunnelApi.post('/tunnel/connect', tunnelConfig);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'connect tunnel');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disconnectTunnel(tunnelName: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await tunnelApi.post('/tunnel/disconnect', { tunnelName });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'disconnect tunnel');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelTunnel(tunnelName: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await tunnelApi.post('/tunnel/cancel', { tunnelName });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'cancel tunnel');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// FILE MANAGER METADATA (Recent, Pinned, Shortcuts)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function getFileManagerRecent(hostId: number): Promise<FileManagerFile[]> {
|
||||||
|
try {
|
||||||
|
const response = await sshHostApi.get(`/file_manager/recent?hostId=${hostId}`);
|
||||||
|
return response.data || [];
|
||||||
|
} catch (error) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addFileManagerRecent(file: FileManagerOperation): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await sshHostApi.post('/file_manager/recent', file);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'add recent file');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeFileManagerRecent(file: FileManagerOperation): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await sshHostApi.delete('/file_manager/recent', { data: file });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'remove recent file');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFileManagerPinned(hostId: number): Promise<FileManagerFile[]> {
|
||||||
|
try {
|
||||||
|
const response = await sshHostApi.get(`/file_manager/pinned?hostId=${hostId}`);
|
||||||
|
return response.data || [];
|
||||||
|
} catch (error) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addFileManagerPinned(file: FileManagerOperation): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await sshHostApi.post('/file_manager/pinned', file);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'add pinned file');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeFileManagerPinned(file: FileManagerOperation): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await sshHostApi.delete('/file_manager/pinned', { data: file });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'remove pinned file');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFileManagerShortcuts(hostId: number): Promise<FileManagerShortcut[]> {
|
||||||
|
try {
|
||||||
|
const response = await sshHostApi.get(`/file_manager/shortcuts?hostId=${hostId}`);
|
||||||
|
return response.data || [];
|
||||||
|
} catch (error) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addFileManagerShortcut(shortcut: FileManagerOperation): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await sshHostApi.post('/file_manager/shortcuts', shortcut);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'add shortcut');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeFileManagerShortcut(shortcut: FileManagerOperation): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await sshHostApi.delete('/file_manager/shortcuts', { data: shortcut });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'remove shortcut');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SSH FILE OPERATIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function connectSSH(sessionId: string, config: {
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
password?: string;
|
||||||
|
sshKey?: string;
|
||||||
|
keyPassword?: string;
|
||||||
|
}): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await fileManagerApi.post('/ssh/connect', {
|
||||||
|
sessionId,
|
||||||
|
...config
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'connect SSH');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disconnectSSH(sessionId: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await fileManagerApi.post('/ssh/disconnect', { sessionId });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'disconnect SSH');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSSHStatus(sessionId: string): Promise<{ connected: boolean }> {
|
||||||
|
try {
|
||||||
|
const response = await fileManagerApi.get('/ssh/status', {
|
||||||
|
params: { sessionId }
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'get SSH status');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSSHFiles(sessionId: string, path: string): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
const response = await fileManagerApi.get('/ssh/listFiles', {
|
||||||
|
params: { sessionId, path }
|
||||||
|
});
|
||||||
|
return response.data || [];
|
||||||
|
} catch (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/readFile', {
|
||||||
|
params: { sessionId, path }
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'read SSH file');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeSSHFile(sessionId: string, path: string, content: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await fileManagerApi.post('/ssh/writeFile', {
|
||||||
|
sessionId,
|
||||||
|
path,
|
||||||
|
content
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data && (response.data.message === 'File written successfully' || response.status === 200)) {
|
||||||
|
return response.data;
|
||||||
|
} else {
|
||||||
|
throw new Error('File write operation did not return success status');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'write SSH file');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadSSHFile(sessionId: string, path: string, fileName: string, content: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await fileManagerApi.post('/ssh/uploadFile', {
|
||||||
|
sessionId,
|
||||||
|
path,
|
||||||
|
fileName,
|
||||||
|
content
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'upload SSH file');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSSHFile(sessionId: string, path: string, fileName: string, content: string = ''): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await fileManagerApi.post('/ssh/createFile', {
|
||||||
|
sessionId,
|
||||||
|
path,
|
||||||
|
fileName,
|
||||||
|
content
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'create SSH file');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSSHFolder(sessionId: string, path: string, folderName: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await fileManagerApi.post('/ssh/createFolder', {
|
||||||
|
sessionId,
|
||||||
|
path,
|
||||||
|
folderName
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'create SSH folder');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSSHItem(sessionId: string, path: string, isDirectory: boolean): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await fileManagerApi.delete('/ssh/deleteItem', {
|
||||||
|
data: {
|
||||||
|
sessionId,
|
||||||
|
path,
|
||||||
|
isDirectory
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'delete SSH item');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameSSHItem(sessionId: string, oldPath: string, newName: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await fileManagerApi.put('/ssh/renameItem', {
|
||||||
|
sessionId,
|
||||||
|
oldPath,
|
||||||
|
newName
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'rename SSH item');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SERVER STATISTICS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function getAllServerStatuses(): Promise<Record<number, ServerStatus>> {
|
||||||
|
try {
|
||||||
|
const response = await statsApi.get('/status');
|
||||||
|
return response.data || {};
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'fetch server statuses');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServerStatusById(id: number): Promise<ServerStatus> {
|
||||||
|
try {
|
||||||
|
const response = await statsApi.get(`/status/${id}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'fetch server status');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServerMetricsById(id: number): Promise<ServerMetrics> {
|
||||||
|
try {
|
||||||
|
const response = await statsApi.get(`/metrics/${id}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'fetch server metrics');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AUTHENTICATION
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function registerUser(username: string, password: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post('/users/create', { username, password });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'register user');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginUser(username: string, password: string): Promise<AuthResponse> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post('/users/login', { username, password });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'login user');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserInfo(): Promise<UserInfo> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.get('/users/me');
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'fetch user info');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRegistrationAllowed(): Promise<{ allowed: boolean }> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.get('/users/registration-allowed');
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'check registration status');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOIDCConfig(): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.get('/users/oidc-config');
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'fetch OIDC config');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserCount(): Promise<UserCount> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.get('/users/count');
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'fetch user count');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initiatePasswordReset(username: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post('/users/initiate-reset', { username });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'initiate password reset');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPasswordResetCode(username: string, resetCode: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post('/users/verify-reset-code', { username, resetCode });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'verify reset code');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completePasswordReset(username: string, tempToken: string, newPassword: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post('/users/complete-reset', { username, tempToken, newPassword });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'complete password reset');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOIDCAuthorizeUrl(): Promise<OIDCAuthorize> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.get('/users/oidc/authorize');
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'get OIDC authorize URL');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// USER MANAGEMENT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function getUserList(): Promise<{ users: UserInfo[] }> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.get('/users/list');
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'fetch user list');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function makeUserAdmin(username: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post('/users/make-admin', { username });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'make user admin');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeAdminStatus(username: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post('/users/remove-admin', { username });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'remove admin status');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(username: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.delete('/users/delete-user', { data: { username } });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'delete user');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAccount(password: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.delete('/users/delete-account', { data: { password } });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'delete account');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRegistrationAllowed(allowed: boolean): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.patch('/users/registration-allowed', { allowed });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'update registration allowed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateOIDCConfig(config: any): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post('/users/oidc-config', config);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'update OIDC config');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ALERTS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function getUserAlerts(userId: string): Promise<{ alerts: any[] }> {
|
||||||
|
try {
|
||||||
|
const apiInstance = createApiInstance(isDev ? 'http://localhost:8081' : '');
|
||||||
|
const response = await apiInstance.get(`/alerts/user/${userId}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'fetch user alerts');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dismissAlert(userId: string, alertId: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
// Use the general API instance since alerts endpoint is at root level
|
||||||
|
const apiInstance = createApiInstance(isDev ? 'http://localhost:8081' : '');
|
||||||
|
const response = await apiInstance.post('/alerts/dismiss', { userId, alertId });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'dismiss alert');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// UPDATES & RELEASES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function getReleasesRSS(perPage: number = 100): Promise<any> {
|
||||||
|
try {
|
||||||
|
// Use the general API instance since releases endpoint is at root level
|
||||||
|
const apiInstance = createApiInstance(isDev ? 'http://localhost:8081' : '');
|
||||||
|
const response = await apiInstance.get(`/releases/rss?per_page=${perPage}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'fetch releases RSS');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVersionInfo(): Promise<any> {
|
||||||
|
try {
|
||||||
|
// Use the general API instance since version endpoint is at root level
|
||||||
|
const apiInstance = createApiInstance(isDev ? 'http://localhost:8081' : '');
|
||||||
|
const response = await apiInstance.get('/version/');
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'fetch version info');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DATABASE HEALTH
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function getDatabaseHealth(): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.get('/users/db-health');
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'check database health');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,16 +6,12 @@
|
|||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
/* Linting - Made extremely permissive */
|
|
||||||
"strict": false,
|
"strict": false,
|
||||||
"noUnusedLocals": false,
|
"noUnusedLocals": false,
|
||||||
"noUnusedParameters": false,
|
"noUnusedParameters": false,
|
||||||
@@ -31,8 +27,6 @@
|
|||||||
"allowUnreachableCode": true,
|
"allowUnreachableCode": true,
|
||||||
"noImplicitOverride": false,
|
"noImplicitOverride": false,
|
||||||
"noEmitOnError": false,
|
"noEmitOnError": false,
|
||||||
|
|
||||||
/* shadcn */
|
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": [
|
"@/*": [
|
||||||
|
|||||||