Compare commits
97 Commits
release-1.
...
release-1.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26c1cacc9d | ||
|
|
8dddbaa86d | ||
|
|
d46fafb421 | ||
|
|
25178928a0 | ||
|
|
2fe9c0f854 | ||
|
|
2f68dc018e | ||
|
|
8b8e77214c | ||
|
|
89589dcf9f | ||
|
|
250ad975d4 | ||
|
|
b649e73c80 | ||
|
|
839e36adb9 | ||
|
|
f02c0c3163 | ||
|
|
83c41751ea | ||
|
|
8058ffd217 | ||
|
|
a3db62b0f8 | ||
|
|
dc43bf1329 | ||
|
|
0a7b1b2bd0 | ||
|
|
94e6617638 | ||
|
|
76995dbc1b | ||
|
|
be6cda7d8a | ||
|
|
9130eb68a8 | ||
|
|
200428498f | ||
|
|
f60c9c72aa | ||
|
|
487919cedc | ||
|
|
b046aedcee | ||
|
|
0c5216933a | ||
|
|
191dc8ba24 | ||
|
|
d88c890ba7 | ||
|
|
a34c60947d | ||
|
|
56fddb6fcb | ||
|
|
b9bd00f86e | ||
|
|
d1ae7f659d | ||
|
|
b127c5ff4f | ||
|
|
75a87400eb | ||
|
|
d4dccdb574 | ||
|
|
a34421f5b2 | ||
|
|
7a5cf94d45 | ||
|
|
134f58b38b | ||
|
|
84dcda9363 | ||
|
|
d81cf20f5e | ||
|
|
86fd8574f1 | ||
|
|
6e175e2b36 | ||
|
|
c7fba8dbb1 | ||
|
|
0c2ac176a7 | ||
|
|
59a38dad0a | ||
|
|
6ac536ebad | ||
|
|
12418eb5b2 | ||
|
|
e364e9b38e | ||
|
|
402f9fa909 | ||
|
|
5ac25e2a26 | ||
|
|
5fbac89e67 | ||
|
|
f2fb938e5f | ||
|
|
cef420d1d8 | ||
|
|
94f69cee3f | ||
|
|
23e72aedfd | ||
|
|
7957ed06e4 | ||
|
|
5f1a2d9a17 | ||
|
|
dbe12ce681 | ||
|
|
d66efdb74f | ||
|
|
c3d1855c65 | ||
|
|
4f4e5296a4 | ||
|
|
61da21c507 | ||
|
|
347e8c40df | ||
|
|
f0ae53c41b | ||
|
|
53f9e888df | ||
|
|
c1d06028c3 | ||
|
|
fa64e98ef9 | ||
|
|
2df2c4e73d | ||
|
|
7d904c4a2c | ||
|
|
cf945e3665 | ||
|
|
880907cc93 | ||
|
|
22162e5b9b | ||
|
|
981705e81d | ||
|
|
5445cb2b78 | ||
|
|
2667af9437 | ||
|
|
58947f4455 | ||
|
|
b7f52d4d73 | ||
|
|
b854a4956c | ||
|
|
07367b24b6 | ||
|
|
1b076cc612 | ||
|
|
81d1db09e4 | ||
|
|
f62bb7f773 | ||
|
|
0906ddfe6e | ||
|
|
96864dbeb4 | ||
|
|
3a663f1b47 | ||
|
|
b9d5965aa6 | ||
|
|
47f8d3f23b | ||
|
|
c71b8b4211 | ||
|
|
07a8fc3e50 | ||
|
|
c90c45ec61 | ||
|
|
4e93ac7d88 | ||
|
|
9ce69a80d7 | ||
|
|
602f21b475 | ||
|
|
3b347f7ae5 | ||
|
|
1f83fb68f0 | ||
|
|
a2481362a2 | ||
|
|
f49c32d26f |
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
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
network=host
|
||||
|
||||
- name: Cache npm dependencies
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.npm
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
${{ runner.os }}-node-
|
||||
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.ref_name }}-${{ hashFiles('docker/Dockerfile') }}
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV
|
||||
|
||||
- name: Build and Push Multi-Arch Docker Image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/Dockerfile
|
||||
|
||||
56
CONTRIBUTING.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Contributing
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/en/download/) (built with v24)
|
||||
- [NPM](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm)
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
|
||||
## Installation
|
||||
|
||||
1. Clone the repository:
|
||||
```sh
|
||||
git clone https://github.com/LukeGus/Termix
|
||||
```
|
||||
2. Install the dependencies:
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
## Running the development server
|
||||
|
||||
Run the following commands:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
npx tsc -p tsconfig.node.json
|
||||
node ./dist/backend/starter.js
|
||||
```
|
||||
|
||||
This will start the backend and the frontend Vite server. You can access Termix by going to `http://localhost:5174/`.
|
||||
|
||||
## Contributing
|
||||
|
||||
1. **Fork the repository**: Click the "Fork" button at the top right of
|
||||
the [repository page](https://github.com/LukeGus/Termix).
|
||||
2. **Create a new branch**:
|
||||
```sh
|
||||
git checkout -b feature/my-new-feature
|
||||
```
|
||||
3. **Make your changes**: Implement your feature, fix, or improvement.
|
||||
4. **Commit your changes**:
|
||||
```sh
|
||||
git commit -m "Add feature: my new feature"
|
||||
```
|
||||
5. **Push to your fork**:
|
||||
```sh
|
||||
git push origin feature/my-new-feature
|
||||
```
|
||||
6. **Open a pull request**: Go to the original repository and create a PR with a clear description.
|
||||
|
||||
## 📝 Guidelines
|
||||
|
||||
- Follow the existing code style. Use Tailwind CSS with shadcn components.
|
||||
- Place all API routes in the `main-axios.ts` file. Updating the `openapi.json` is unneeded.
|
||||
- Include meaningful commit messages.
|
||||
- Link related issues when applicable.
|
||||
29
README.md
@@ -13,36 +13,41 @@
|
||||
[](#)
|
||||
[](#)
|
||||
|
||||
|
||||
<br />
|
||||
<p align="center">
|
||||
<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>
|
||||
|
||||
If you would like, you can support the project here!\
|
||||
[](https://github.com/sponsors/LukeGus)
|
||||
|
||||
# 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
|
||||
- **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
|
||||
- **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
|
||||
- **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
|
||||
- **Server Stats** - View CPU, memory, and HDD usage on any SSH server
|
||||
- **User Authentication** - Secure user management with admin controls and OIDC and 2FA (TOTP) support
|
||||
- **Modern UI** - Clean interface built with React, Tailwind CSS, and Shadcn
|
||||
|
||||
# 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
|
||||
- **More auth types** - Add 2FA, TOTP, etc
|
||||
- **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 Admin Control** - Give more fine-grained control over user and admin permissions, share hosts, etc
|
||||
- **Theming** - Modify theming for all tools
|
||||
- **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
|
||||
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/install) for more information on how to install Termix. Otherwise, view a sample docker-compose file here:
|
||||
```yaml
|
||||
services:
|
||||
termix:
|
||||
@@ -78,7 +83,7 @@ If you need help with Termix, you can join the [Discord](https://discord.gg/jVQG
|
||||
</p>
|
||||
|
||||
<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.
|
||||
</video>
|
||||
</p>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Stage 1: Install dependencies and build frontend
|
||||
FROM node:18-alpine AS deps
|
||||
FROM node:24-alpine AS deps
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache python3 make g++
|
||||
@@ -26,7 +26,7 @@ COPY . .
|
||||
RUN npm run build:backend
|
||||
|
||||
# Stage 4: Production dependencies
|
||||
FROM node:18-alpine AS production-deps
|
||||
FROM node:24-alpine AS production-deps
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
@@ -35,7 +35,7 @@ RUN npm ci --only=production --ignore-scripts --force && \
|
||||
npm cache clean --force
|
||||
|
||||
# Stage 5: Build native modules
|
||||
FROM node:18-alpine AS native-builder
|
||||
FROM node:24-alpine AS native-builder
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache python3 make g++
|
||||
@@ -46,7 +46,7 @@ RUN npm ci --only=production bcryptjs better-sqlite3 --force && \
|
||||
npm cache clean --force
|
||||
|
||||
# Stage 6: Final image
|
||||
FROM node:18-alpine
|
||||
FROM node:24-alpine
|
||||
ENV DATA_DIR=/app/data \
|
||||
PORT=8080 \
|
||||
NODE_ENV=production
|
||||
@@ -72,8 +72,8 @@ RUN chown -R node:node /app
|
||||
|
||||
VOLUME ["/app/data"]
|
||||
|
||||
EXPOSE ${PORT} 8081 8082 8083 8084
|
||||
EXPOSE ${PORT} 8081 8082 8083 8084 8085
|
||||
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
CMD ["/entrypoint.sh"]
|
||||
CMD ["/entrypoint.sh"]
|
||||
|
||||
@@ -45,7 +45,16 @@ http {
|
||||
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_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
@@ -62,6 +71,14 @@ http {
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_connect_timeout 75s;
|
||||
proxy_set_header Connection "";
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
@@ -76,7 +93,34 @@ http {
|
||||
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_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
@@ -85,8 +129,17 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/ssh/config_editor/(recent|pinned|shortcuts) {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
location /status/ {
|
||||
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_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
2262
openapi.json
Normal file
835
package-lock.json
generated
23
package.json
@@ -17,10 +17,11 @@
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@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-label": "^2.1.7",
|
||||
"@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-select": "^2.2.5",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
@@ -28,10 +29,12 @@
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@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",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/speakeasy": "^2.0.10",
|
||||
"@uiw/codemirror-extensions-hyper-link": "^4.24.1",
|
||||
"@uiw/codemirror-extensions-langs": "^4.24.1",
|
||||
"@uiw/codemirror-themes": "^4.24.1",
|
||||
@@ -59,12 +62,16 @@
|
||||
"lucide-react": "^0.525.0",
|
||||
"multer": "^2.0.2",
|
||||
"nanoid": "^5.1.5",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-fetch": "^3.3.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "^7.60.0",
|
||||
"react-resizable-panels": "^3.0.3",
|
||||
"react-xtermjs": "^1.0.10",
|
||||
"sonner": "^2.0.7",
|
||||
"speakeasy": "^2.0.0",
|
||||
"ssh2": "^1.16.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.11",
|
||||
@@ -73,26 +80,26 @@
|
||||
"zod": "^4.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.30.1",
|
||||
"@eslint/js": "^9.34.0",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^24.0.13",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitejs/plugin-react-swc": "^3.10.2",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.30.1",
|
||||
"eslint": "^9.34.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"tw-animate-css": "^1.3.5",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.35.1",
|
||||
"vite": "^7.0.4"
|
||||
"typescript": "~5.9.2",
|
||||
"typescript-eslint": "^8.40.0",
|
||||
"vite": "^7.1.3"
|
||||
}
|
||||
}
|
||||
|
||||
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 |
236
src/App.tsx
@@ -1,14 +1,70 @@
|
||||
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 { UserProfile } from "@/ui/User/UserProfile.tsx";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { getUserInfo } from "@/ui/main-axios.ts";
|
||||
|
||||
import {Homepage} from "@/apps/Homepage/Homepage.tsx"
|
||||
import {Terminal} from "@/apps/SSH/Terminal/Terminal.tsx"
|
||||
import {SSHTunnel} from "@/apps/SSH/Tunnel/SSHTunnel.tsx";
|
||||
import {ConfigEditor} from "@/apps/SSH/Config Editor/ConfigEditor.tsx";
|
||||
import {SSHManager} from "@/apps/SSH/Manager/SSHManager.tsx"
|
||||
function getCookie(name: string) {
|
||||
return document.cookie.split('; ').reduce((r, v) => {
|
||||
const parts = v.split('=');
|
||||
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
|
||||
}, "");
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [view, setView] = React.useState<string>("homepage")
|
||||
const [mountedViews, setMountedViews] = React.useState<Set<string>>(new Set(["homepage"]))
|
||||
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 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) => {
|
||||
setMountedViews((prev) => {
|
||||
@@ -20,37 +76,153 @@ function App() {
|
||||
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';
|
||||
const showProfile = currentTabData?.type === 'profile';
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh w-full">
|
||||
<main className="flex-1 w-full">
|
||||
{mountedViews.has("homepage") && (
|
||||
<div style={{display: view === "homepage" ? "block" : "none"}}>
|
||||
<Homepage onSelectView={handleSelectView} />
|
||||
<div>
|
||||
{!isAuthenticated && !authLoading && (
|
||||
<div>
|
||||
<div className="absolute inset-0" style={{
|
||||
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>
|
||||
)}
|
||||
|
||||
{!isAuthenticated && !authLoading && (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[10000]">
|
||||
<Homepage
|
||||
onSelectView={handleSelectView}
|
||||
isAuthenticated={isAuthenticated}
|
||||
authLoading={authLoading}
|
||||
onAuthSuccess={handleAuthSuccess}
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAuthenticated && (
|
||||
<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>
|
||||
)}
|
||||
{mountedViews.has("ssh_manager") && (
|
||||
<div style={{display: view === "ssh_manager" ? "block" : "none"}}>
|
||||
<SSHManager onSelectView={handleSelectView} />
|
||||
|
||||
<div
|
||||
className="h-screen w-full"
|
||||
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>
|
||||
)}
|
||||
{mountedViews.has("terminal") && (
|
||||
<div style={{display: view === "terminal" ? "block" : "none"}}>
|
||||
<Terminal onSelectView={handleSelectView} />
|
||||
|
||||
<div
|
||||
className="h-screen w-full"
|
||||
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>
|
||||
)}
|
||||
{mountedViews.has("tunnel") && (
|
||||
<div style={{display: view === "tunnel" ? "block" : "none"}}>
|
||||
<SSHTunnel onSelectView={handleSelectView} />
|
||||
|
||||
<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>
|
||||
)}
|
||||
{mountedViews.has("config_editor") && (
|
||||
<div style={{display: view === "config_editor" ? "block" : "none"}}>
|
||||
<ConfigEditor onSelectView={handleSelectView} />
|
||||
|
||||
<div
|
||||
className="h-screen w-full"
|
||||
style={{
|
||||
visibility: showProfile ? "visible" : "hidden",
|
||||
pointerEvents: showProfile ? "auto" : "none",
|
||||
height: showProfile ? "100vh" : 0,
|
||||
width: showProfile ? "100%" : 0,
|
||||
position: showProfile ? "static" : "absolute",
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
<UserProfile isTopbarOpen={isTopbarOpen} />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<TopNavbar isTopbarOpen={isTopbarOpen} setIsTopbarOpen={setIsTopbarOpen}/>
|
||||
</LeftSidebar>
|
||||
)}
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
richColors={false}
|
||||
closeButton
|
||||
duration={5000}
|
||||
offset={20}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<TabProvider>
|
||||
<AppContent />
|
||||
</TabProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -1,109 +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";
|
||||
import {HomepageWelcomeCard} from "@/apps/Homepage/HomepageWelcomeCard.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);
|
||||
const [showWelcomeCard, setShowWelcomeCard] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const jwt = getCookie("jwt");
|
||||
const welcomeHidden = getCookie("welcome_hidden");
|
||||
|
||||
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);
|
||||
setShowWelcomeCard(welcomeHidden !== "true");
|
||||
})
|
||||
.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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleHideWelcomeCard = () => {
|
||||
setShowWelcomeCard(false);
|
||||
setCookie("welcome_hidden", "true", 365 * 10);
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
{loggedIn && !authLoading && showWelcomeCard && (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-10">
|
||||
<HomepageWelcomeCard onHidePermanently={handleHideWelcomeCard}/>
|
||||
</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,58 +0,0 @@
|
||||
import React from "react";
|
||||
import {Card, CardContent, CardFooter, CardHeader, CardTitle} from "@/components/ui/card";
|
||||
import {Button} from "@/components/ui/button";
|
||||
|
||||
interface HomepageWelcomeCardProps {
|
||||
onHidePermanently: () => void;
|
||||
}
|
||||
|
||||
export function HomepageWelcomeCard({onHidePermanently}: HomepageWelcomeCardProps): React.ReactElement {
|
||||
return (
|
||||
<Card className="w-full max-w-2xl mx-auto">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold text-center">
|
||||
The Future of Termix
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground text-center leading-relaxed">
|
||||
Please checkout the linked survey{" "}
|
||||
<a
|
||||
href="https://docs.google.com/forms/d/e/1FAIpQLSeGvnQODFtnpjmJsMKgASbaQ87CLQEBCcnzK_Vuw5TdfbfIyA/viewform?usp=sharing&ouid=107601685503825301492"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline hover:text-primary/80 transition-colors"
|
||||
>
|
||||
here
|
||||
</a>
|
||||
. The purpose of this survey is to gather feedback from users on what the future UI of Termix could
|
||||
look like to optimize server management. Please take a minute or two to read the survey questions
|
||||
and answer them to the best of your ability. Thank you!
|
||||
</p>
|
||||
<p className="text-muted-foreground text-center leading-relaxed mt-6">
|
||||
A special thanks to those in Asia who recently joined Termix through various forum posts, keep
|
||||
sharing it! A Chinese translation is planned for Termix, but since I don’t speak Chinese, I’ll need
|
||||
to hire someone to help with the translation. If you’d like to support me financially, you can do
|
||||
so{" "}
|
||||
<a
|
||||
href="https://github.com/sponsors/LukeGus"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline hover:text-primary/80 transition-colors"
|
||||
>
|
||||
here.
|
||||
</a>
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onHidePermanently}
|
||||
className="w-full max-w-xs"
|
||||
>
|
||||
Hide Permanently
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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,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,784 +0,0 @@
|
||||
import React, {useState, useRef, useEffect} from "react";
|
||||
import {TerminalSidebar} from "@/apps/SSH/Terminal/TerminalSidebar.tsx";
|
||||
import {TerminalComponent} from "./TerminalComponent.tsx";
|
||||
import {TerminalTopbar} from "@/apps/SSH/Terminal/TerminalTopbar.tsx";
|
||||
import {ResizablePanelGroup, ResizablePanel, ResizableHandle} from '@/components/ui/resizable.tsx';
|
||||
import * as ResizablePrimitive from "react-resizable-panels";
|
||||
import {ChevronDown, ChevronRight} from "lucide-react";
|
||||
|
||||
interface ConfigEditorProps {
|
||||
onSelectView: (view: string) => void;
|
||||
}
|
||||
|
||||
type Tab = {
|
||||
id: number;
|
||||
title: string;
|
||||
hostConfig: any;
|
||||
terminalRef: React.RefObject<any>;
|
||||
};
|
||||
|
||||
export function Terminal({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 = 10;
|
||||
|
||||
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}>
|
||||
<TerminalComponent
|
||||
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'}}>
|
||||
<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',
|
||||
}}
|
||||
>
|
||||
<TerminalSidebar
|
||||
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',
|
||||
}}
|
||||
>
|
||||
<TerminalTopbar
|
||||
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,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
title="Show top bar">
|
||||
<ChevronDown size={HANDLE_THICKNESS} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
|
||||
{!isSidebarOpen && (
|
||||
<div
|
||||
onClick={() => setIsSidebarOpen(true)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: HANDLE_THICKNESS,
|
||||
height: '100%',
|
||||
background: '#222224',
|
||||
cursor: 'pointer',
|
||||
zIndex: 20,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
title="Show sidebar">
|
||||
<ChevronRight size={HANDLE_THICKNESS} />
|
||||
</div>
|
||||
)}
|
||||
</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 TerminalSidebar({
|
||||
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 TerminalTabList({
|
||||
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 {TerminalTabList} from "@/apps/SSH/Terminal/TerminalTabList.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 TerminalTopbar({
|
||||
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'}}>
|
||||
<TerminalTabList
|
||||
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 userRoutes from './routes/users.js';
|
||||
import sshRoutes from './routes/ssh.js';
|
||||
import alertRoutes from './routes/alerts.js';
|
||||
import chalk from 'chalk';
|
||||
import cors from 'cors';
|
||||
import fetch from 'node-fetch';
|
||||
@@ -101,10 +102,10 @@ interface GitHubRelease {
|
||||
async function fetchGitHubAPI(endpoint: string, cacheKey: string): Promise<any> {
|
||||
const cachedData = githubCache.get(cacheKey);
|
||||
if (cachedData) {
|
||||
return {
|
||||
data: cachedData,
|
||||
cached: true,
|
||||
cache_age: Date.now() - cachedData.timestamp
|
||||
return {
|
||||
data: cachedData,
|
||||
cached: true,
|
||||
cache_age: Date.now() - cachedData.timestamp
|
||||
};
|
||||
}
|
||||
|
||||
@@ -124,10 +125,10 @@ async function fetchGitHubAPI(endpoint: string, cacheKey: string): Promise<any>
|
||||
const data = await response.json();
|
||||
|
||||
githubCache.set(cacheKey, data);
|
||||
|
||||
return {
|
||||
data: data,
|
||||
cached: false
|
||||
|
||||
return {
|
||||
data: data,
|
||||
cached: false
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch from GitHub API: ${endpoint}`, error);
|
||||
@@ -227,12 +228,16 @@ app.get('/releases/rss', async (req, res) => {
|
||||
res.json(response);
|
||||
} catch (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('/ssh', sshRoutes);
|
||||
app.use('/alerts', alertRoutes);
|
||||
|
||||
app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
logger.error('Unhandled error:', err);
|
||||
@@ -240,4 +245,5 @@ app.use((err: unknown, req: express.Request, res: express.Response, next: expres
|
||||
});
|
||||
|
||||
const PORT = 8081;
|
||||
app.listen(PORT, () => {});
|
||||
app.listen(PORT, () => {
|
||||
});
|
||||
@@ -41,86 +41,340 @@ const dbPath = path.join(dataDir, 'db.sqlite');
|
||||
const sqlite = new Database(dbPath);
|
||||
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT 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,
|
||||
client_id TEXT NOT 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 users
|
||||
(
|
||||
id
|
||||
TEXT
|
||||
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,
|
||||
client_id
|
||||
TEXT
|
||||
NOT
|
||||
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 (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
CREATE TABLE IF NOT EXISTS settings
|
||||
(
|
||||
key
|
||||
TEXT
|
||||
PRIMARY
|
||||
KEY,
|
||||
value
|
||||
TEXT
|
||||
NOT
|
||||
NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
ip TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
username 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_config_editor 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 ssh_data
|
||||
(
|
||||
id
|
||||
INTEGER
|
||||
PRIMARY
|
||||
KEY
|
||||
AUTOINCREMENT,
|
||||
user_id
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
name
|
||||
TEXT,
|
||||
ip
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
port
|
||||
INTEGER
|
||||
NOT
|
||||
NULL,
|
||||
username
|
||||
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 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_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 file_manager_recent
|
||||
(
|
||||
id
|
||||
INTEGER
|
||||
PRIMARY
|
||||
KEY
|
||||
AUTOINCREMENT,
|
||||
user_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 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_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 file_manager_pinned
|
||||
(
|
||||
id
|
||||
INTEGER
|
||||
PRIMARY
|
||||
KEY
|
||||
AUTOINCREMENT,
|
||||
user_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 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_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 file_manager_shortcuts
|
||||
(
|
||||
id
|
||||
INTEGER
|
||||
PRIMARY
|
||||
KEY
|
||||
AUTOINCREMENT,
|
||||
user_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
|
||||
)
|
||||
);
|
||||
`);
|
||||
|
||||
const addColumnIfNotExists = (table: string, column: string, definition: string) => {
|
||||
@@ -151,13 +405,17 @@ const migrateSchema = () => {
|
||||
addColumnIfNotExists('users', 'token_url', 'TEXT');
|
||||
try {
|
||||
sqlite.prepare(`ALTER TABLE users DROP COLUMN redirect_uri`).run();
|
||||
logger.info('Removed redirect_uri column from users table');
|
||||
} catch (e) {
|
||||
}
|
||||
|
||||
|
||||
addColumnIfNotExists('users', 'identifier_path', 'TEXT');
|
||||
addColumnIfNotExists('users', 'name_path', 'TEXT');
|
||||
addColumnIfNotExists('users', 'scopes', 'TEXT');
|
||||
|
||||
// Add TOTP columns
|
||||
addColumnIfNotExists('users', 'totp_secret', 'TEXT');
|
||||
addColumnIfNotExists('users', 'totp_enabled', 'INTEGER NOT NULL DEFAULT 0');
|
||||
addColumnIfNotExists('users', 'totp_backup_codes', 'TEXT');
|
||||
|
||||
addColumnIfNotExists('ssh_data', 'name', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'folder', 'TEXT');
|
||||
@@ -171,14 +429,14 @@ const migrateSchema = () => {
|
||||
addColumnIfNotExists('ssh_data', 'enable_terminal', 'INTEGER NOT NULL DEFAULT 1');
|
||||
addColumnIfNotExists('ssh_data', 'enable_tunnel', 'INTEGER NOT NULL DEFAULT 1');
|
||||
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', 'created_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('config_editor_pinned', 'host_id', 'INTEGER NOT NULL');
|
||||
addColumnIfNotExists('config_editor_shortcuts', 'host_id', 'INTEGER NOT NULL');
|
||||
addColumnIfNotExists('file_manager_recent', 'host_id', 'INTEGER NOT NULL');
|
||||
addColumnIfNotExists('file_manager_pinned', 'host_id', 'INTEGER NOT NULL');
|
||||
addColumnIfNotExists('file_manager_shortcuts', 'host_id', 'INTEGER NOT NULL');
|
||||
|
||||
logger.success('Schema migration completed');
|
||||
};
|
||||
|
||||
@@ -17,6 +17,10 @@ export const users = sqliteTable('users', {
|
||||
identifier_path: text('identifier_path'),
|
||||
name_path: text('name_path'),
|
||||
scopes: text().default("openid email profile"),
|
||||
|
||||
totp_secret: text('totp_secret'),
|
||||
totp_enabled: integer('totp_enabled', {mode: 'boolean'}).notNull().default(false),
|
||||
totp_backup_codes: text('totp_backup_codes'),
|
||||
});
|
||||
|
||||
export const settings = sqliteTable('settings', {
|
||||
@@ -42,13 +46,13 @@ export const sshData = sqliteTable('ssh_data', {
|
||||
enableTerminal: integer('enable_terminal', {mode: 'boolean'}).notNull().default(true),
|
||||
enableTunnel: integer('enable_tunnel', {mode: 'boolean'}).notNull().default(true),
|
||||
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'),
|
||||
createdAt: text('created_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}),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
hostId: integer('host_id').notNull().references(() => sshData.id),
|
||||
@@ -57,7 +61,7 @@ export const configEditorRecent = sqliteTable('config_editor_recent', {
|
||||
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}),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
hostId: integer('host_id').notNull().references(() => sshData.id),
|
||||
@@ -66,11 +70,18 @@ export const configEditorPinned = sqliteTable('config_editor_pinned', {
|
||||
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}),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
hostId: integer('host_id').notNull().references(() => sshData.id),
|
||||
name: text('name').notNull(),
|
||||
path: text('path').notNull(),
|
||||
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 {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 chalk from 'chalk';
|
||||
import jwt from 'jsonwebtoken';
|
||||
@@ -94,7 +94,6 @@ router.get('/db/host/internal', async (req: Request, res: Response) => {
|
||||
}
|
||||
try {
|
||||
const data = await db.select().from(sshData);
|
||||
// Convert tags to array, booleans to bool, tunnelConnections to array
|
||||
const result = data.map((row: any) => ({
|
||||
...row,
|
||||
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,
|
||||
enableTunnel: !!row.enableTunnel,
|
||||
tunnelConnections: row.tunnelConnections ? JSON.parse(row.tunnelConnections) : [],
|
||||
enableConfigEditor: !!row.enableConfigEditor,
|
||||
enableFileManager: !!row.enableFileManager,
|
||||
}));
|
||||
res.json(result);
|
||||
} 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) => {
|
||||
let hostData: any;
|
||||
|
||||
// Check if this is a multipart form data request (file upload)
|
||||
if (req.headers['content-type']?.includes('multipart/form-data')) {
|
||||
// Parse the JSON data from the 'data' field
|
||||
if (req.body.data) {
|
||||
try {
|
||||
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'});
|
||||
}
|
||||
|
||||
// Add the file data if present
|
||||
if (req.file) {
|
||||
hostData.key = req.file.buffer.toString('utf8');
|
||||
}
|
||||
} else {
|
||||
// Regular JSON request
|
||||
hostData = req.body;
|
||||
}
|
||||
|
||||
@@ -155,7 +150,7 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
|
||||
pin,
|
||||
enableTerminal,
|
||||
enableTunnel,
|
||||
enableConfigEditor,
|
||||
enableFileManager,
|
||||
defaultPath,
|
||||
tunnelConnections
|
||||
} = hostData;
|
||||
@@ -178,7 +173,7 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
|
||||
enableTerminal: !!enableTerminal ? 1 : 0,
|
||||
enableTunnel: !!enableTunnel ? 1 : 0,
|
||||
tunnelConnections: Array.isArray(tunnelConnections) ? JSON.stringify(tunnelConnections) : null,
|
||||
enableConfigEditor: !!enableConfigEditor ? 1 : 0,
|
||||
enableFileManager: !!enableFileManager ? 1 : 0,
|
||||
defaultPath: defaultPath || null,
|
||||
};
|
||||
|
||||
@@ -243,7 +238,7 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re
|
||||
pin,
|
||||
enableTerminal,
|
||||
enableTunnel,
|
||||
enableConfigEditor,
|
||||
enableFileManager,
|
||||
defaultPath,
|
||||
tunnelConnections
|
||||
} = hostData;
|
||||
@@ -266,7 +261,7 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re
|
||||
enableTerminal: !!enableTerminal ? 1 : 0,
|
||||
enableTunnel: !!enableTunnel ? 1 : 0,
|
||||
tunnelConnections: Array.isArray(tunnelConnections) ? JSON.stringify(tunnelConnections) : null,
|
||||
enableConfigEditor: !!enableConfigEditor ? 1 : 0,
|
||||
enableFileManager: !!enableFileManager ? 1 : 0,
|
||||
defaultPath: defaultPath || null,
|
||||
};
|
||||
|
||||
@@ -313,7 +308,7 @@ router.get('/db/host', authenticateJWT, async (req: Request, res: Response) => {
|
||||
enableTerminal: !!row.enableTerminal,
|
||||
enableTunnel: !!row.enableTunnel,
|
||||
tunnelConnections: row.tunnelConnections ? JSON.parse(row.tunnelConnections) : [],
|
||||
enableConfigEditor: !!row.enableConfigEditor,
|
||||
enableFileManager: !!row.enableFileManager,
|
||||
}));
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
@@ -351,7 +346,7 @@ router.get('/db/host/:id', authenticateJWT, async (req: Request, res: Response)
|
||||
enableTerminal: !!host.enableTerminal,
|
||||
enableTunnel: !!host.enableTunnel,
|
||||
tunnelConnections: host.tunnelConnections ? JSON.parse(host.tunnelConnections) : [],
|
||||
enableConfigEditor: !!host.enableConfigEditor,
|
||||
enableFileManager: !!host.enableFileManager,
|
||||
};
|
||||
|
||||
res.json(result);
|
||||
@@ -411,8 +406,8 @@ router.delete('/db/host/:id', authenticateJWT, async (req: Request, res: Respons
|
||||
});
|
||||
|
||||
// Route: Get recent files (requires JWT)
|
||||
// GET /ssh/config_editor/recent
|
||||
router.get('/config_editor/recent', authenticateJWT, async (req: Request, res: Response) => {
|
||||
// GET /ssh/file_manager/recent
|
||||
router.get('/file_manager/recent', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
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 {
|
||||
const recentFiles = await db
|
||||
.select()
|
||||
.from(configEditorRecent)
|
||||
.from(fileManagerRecent)
|
||||
.where(and(
|
||||
eq(configEditorRecent.userId, userId),
|
||||
eq(configEditorRecent.hostId, hostId)
|
||||
eq(fileManagerRecent.userId, userId),
|
||||
eq(fileManagerRecent.hostId, hostId)
|
||||
))
|
||||
.orderBy(desc(configEditorRecent.lastOpened));
|
||||
.orderBy(desc(fileManagerRecent.lastOpened));
|
||||
res.json(recentFiles);
|
||||
} catch (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)
|
||||
// POST /ssh/config_editor/recent
|
||||
router.post('/config_editor/recent', authenticateJWT, async (req: Request, res: Response) => {
|
||||
// POST /ssh/file_manager/recent
|
||||
router.post('/file_manager/recent', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const {name, path, hostId} = req.body;
|
||||
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
|
||||
@@ -453,24 +448,23 @@ router.post('/config_editor/recent', authenticateJWT, async (req: Request, res:
|
||||
}
|
||||
try {
|
||||
const conditions = [
|
||||
eq(configEditorRecent.userId, userId),
|
||||
eq(configEditorRecent.path, path),
|
||||
eq(configEditorRecent.hostId, hostId)
|
||||
eq(fileManagerRecent.userId, userId),
|
||||
eq(fileManagerRecent.path, path),
|
||||
eq(fileManagerRecent.hostId, hostId)
|
||||
];
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(configEditorRecent)
|
||||
.from(fileManagerRecent)
|
||||
.where(and(...conditions));
|
||||
|
||||
if (existing.length > 0) {
|
||||
await db
|
||||
.update(configEditorRecent)
|
||||
.update(fileManagerRecent)
|
||||
.set({lastOpened: new Date().toISOString()})
|
||||
.where(and(...conditions));
|
||||
} else {
|
||||
// Add new recent file
|
||||
await db.insert(configEditorRecent).values({
|
||||
await db.insert(fileManagerRecent).values({
|
||||
userId,
|
||||
hostId,
|
||||
name,
|
||||
@@ -486,8 +480,8 @@ router.post('/config_editor/recent', authenticateJWT, async (req: Request, res:
|
||||
});
|
||||
|
||||
// Route: Remove file from recent (requires JWT)
|
||||
// DELETE /ssh/config_editor/recent
|
||||
router.delete('/config_editor/recent', authenticateJWT, async (req: Request, res: Response) => {
|
||||
// DELETE /ssh/file_manager/recent
|
||||
router.delete('/file_manager/recent', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const {name, path, hostId} = req.body;
|
||||
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
|
||||
@@ -496,13 +490,13 @@ router.delete('/config_editor/recent', authenticateJWT, async (req: Request, res
|
||||
}
|
||||
try {
|
||||
const conditions = [
|
||||
eq(configEditorRecent.userId, userId),
|
||||
eq(configEditorRecent.path, path),
|
||||
eq(configEditorRecent.hostId, hostId)
|
||||
eq(fileManagerRecent.userId, userId),
|
||||
eq(fileManagerRecent.path, path),
|
||||
eq(fileManagerRecent.hostId, hostId)
|
||||
];
|
||||
|
||||
const result = await db
|
||||
.delete(configEditorRecent)
|
||||
.delete(fileManagerRecent)
|
||||
.where(and(...conditions));
|
||||
res.json({message: 'File removed from recent'});
|
||||
} catch (err) {
|
||||
@@ -512,8 +506,8 @@ router.delete('/config_editor/recent', authenticateJWT, async (req: Request, res
|
||||
});
|
||||
|
||||
// Route: Get pinned files (requires JWT)
|
||||
// GET /ssh/config_editor/pinned
|
||||
router.get('/config_editor/pinned', authenticateJWT, async (req: Request, res: Response) => {
|
||||
// GET /ssh/file_manager/pinned
|
||||
router.get('/file_manager/pinned', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
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 {
|
||||
const pinnedFiles = await db
|
||||
.select()
|
||||
.from(configEditorPinned)
|
||||
.from(fileManagerPinned)
|
||||
.where(and(
|
||||
eq(configEditorPinned.userId, userId),
|
||||
eq(configEditorPinned.hostId, hostId)
|
||||
eq(fileManagerPinned.userId, userId),
|
||||
eq(fileManagerPinned.hostId, hostId)
|
||||
))
|
||||
.orderBy(configEditorPinned.pinnedAt);
|
||||
.orderBy(fileManagerPinned.pinnedAt);
|
||||
res.json(pinnedFiles);
|
||||
} catch (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)
|
||||
// POST /ssh/config_editor/pinned
|
||||
router.post('/config_editor/pinned', authenticateJWT, async (req: Request, res: Response) => {
|
||||
// POST /ssh/file_manager/pinned
|
||||
router.post('/file_manager/pinned', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const {name, path, hostId} = req.body;
|
||||
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
|
||||
@@ -554,18 +548,18 @@ router.post('/config_editor/pinned', authenticateJWT, async (req: Request, res:
|
||||
}
|
||||
try {
|
||||
const conditions = [
|
||||
eq(configEditorPinned.userId, userId),
|
||||
eq(configEditorPinned.path, path),
|
||||
eq(configEditorPinned.hostId, hostId)
|
||||
eq(fileManagerPinned.userId, userId),
|
||||
eq(fileManagerPinned.path, path),
|
||||
eq(fileManagerPinned.hostId, hostId)
|
||||
];
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(configEditorPinned)
|
||||
.from(fileManagerPinned)
|
||||
.where(and(...conditions));
|
||||
|
||||
if (existing.length === 0) {
|
||||
await db.insert(configEditorPinned).values({
|
||||
await db.insert(fileManagerPinned).values({
|
||||
userId,
|
||||
hostId,
|
||||
name,
|
||||
@@ -581,8 +575,8 @@ router.post('/config_editor/pinned', authenticateJWT, async (req: Request, res:
|
||||
});
|
||||
|
||||
// Route: Remove file from pinned (requires JWT)
|
||||
// DELETE /ssh/config_editor/pinned
|
||||
router.delete('/config_editor/pinned', authenticateJWT, async (req: Request, res: Response) => {
|
||||
// DELETE /ssh/file_manager/pinned
|
||||
router.delete('/file_manager/pinned', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const {name, path, hostId} = req.body;
|
||||
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
|
||||
@@ -591,13 +585,13 @@ router.delete('/config_editor/pinned', authenticateJWT, async (req: Request, res
|
||||
}
|
||||
try {
|
||||
const conditions = [
|
||||
eq(configEditorPinned.userId, userId),
|
||||
eq(configEditorPinned.path, path),
|
||||
eq(configEditorPinned.hostId, hostId)
|
||||
eq(fileManagerPinned.userId, userId),
|
||||
eq(fileManagerPinned.path, path),
|
||||
eq(fileManagerPinned.hostId, hostId)
|
||||
];
|
||||
|
||||
const result = await db
|
||||
.delete(configEditorPinned)
|
||||
.delete(fileManagerPinned)
|
||||
.where(and(...conditions));
|
||||
res.json({message: 'File unpinned successfully'});
|
||||
} catch (err) {
|
||||
@@ -607,8 +601,8 @@ router.delete('/config_editor/pinned', authenticateJWT, async (req: Request, res
|
||||
});
|
||||
|
||||
// Route: Get folder shortcuts (requires JWT)
|
||||
// GET /ssh/config_editor/shortcuts
|
||||
router.get('/config_editor/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
|
||||
// GET /ssh/file_manager/shortcuts
|
||||
router.get('/file_manager/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
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 {
|
||||
const shortcuts = await db
|
||||
.select()
|
||||
.from(configEditorShortcuts)
|
||||
.from(fileManagerShortcuts)
|
||||
.where(and(
|
||||
eq(configEditorShortcuts.userId, userId),
|
||||
eq(configEditorShortcuts.hostId, hostId)
|
||||
eq(fileManagerShortcuts.userId, userId),
|
||||
eq(fileManagerShortcuts.hostId, hostId)
|
||||
))
|
||||
.orderBy(configEditorShortcuts.createdAt);
|
||||
.orderBy(fileManagerShortcuts.createdAt);
|
||||
res.json(shortcuts);
|
||||
} catch (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)
|
||||
// POST /ssh/config_editor/shortcuts
|
||||
router.post('/config_editor/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
|
||||
// POST /ssh/file_manager/shortcuts
|
||||
router.post('/file_manager/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const {name, path, hostId} = req.body;
|
||||
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
|
||||
@@ -646,18 +640,18 @@ router.post('/config_editor/shortcuts', authenticateJWT, async (req: Request, re
|
||||
}
|
||||
try {
|
||||
const conditions = [
|
||||
eq(configEditorShortcuts.userId, userId),
|
||||
eq(configEditorShortcuts.path, path),
|
||||
eq(configEditorShortcuts.hostId, hostId)
|
||||
eq(fileManagerShortcuts.userId, userId),
|
||||
eq(fileManagerShortcuts.path, path),
|
||||
eq(fileManagerShortcuts.hostId, hostId)
|
||||
];
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(configEditorShortcuts)
|
||||
.from(fileManagerShortcuts)
|
||||
.where(and(...conditions));
|
||||
|
||||
if (existing.length === 0) {
|
||||
await db.insert(configEditorShortcuts).values({
|
||||
await db.insert(fileManagerShortcuts).values({
|
||||
userId,
|
||||
hostId,
|
||||
name,
|
||||
@@ -673,8 +667,8 @@ router.post('/config_editor/shortcuts', authenticateJWT, async (req: Request, re
|
||||
});
|
||||
|
||||
// Route: Remove folder shortcut (requires JWT)
|
||||
// DELETE /ssh/config_editor/shortcuts
|
||||
router.delete('/config_editor/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
|
||||
// DELETE /ssh/file_manager/shortcuts
|
||||
router.delete('/file_manager/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const {name, path, hostId} = req.body;
|
||||
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
|
||||
@@ -682,13 +676,13 @@ router.delete('/config_editor/shortcuts', authenticateJWT, async (req: Request,
|
||||
}
|
||||
try {
|
||||
const conditions = [
|
||||
eq(configEditorShortcuts.userId, userId),
|
||||
eq(configEditorShortcuts.path, path),
|
||||
eq(configEditorShortcuts.hostId, hostId)
|
||||
eq(fileManagerShortcuts.userId, userId),
|
||||
eq(fileManagerShortcuts.path, path),
|
||||
eq(fileManagerShortcuts.hostId, hostId)
|
||||
];
|
||||
|
||||
const result = await db
|
||||
.delete(configEditorShortcuts)
|
||||
.delete(fileManagerShortcuts)
|
||||
.where(and(...conditions));
|
||||
res.json({message: 'Shortcut removed successfully'});
|
||||
} 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;
|
||||
1032
src/backend/ssh/file-manager.ts
Normal file
454
src/backend/ssh/server-stats.ts
Normal file
@@ -0,0 +1,454 @@
|
||||
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);
|
||||
const now = new Date().toISOString();
|
||||
const statusEntry: StatusEntry = {status: isOnline ? 'online' : 'offline', lastChecked: now};
|
||||
hostStatuses.set(h.id, statusEntry);
|
||||
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'});
|
||||
}
|
||||
|
||||
try {
|
||||
const host = await fetchHostById(id);
|
||||
if (!host) {
|
||||
return res.status(404).json({error: 'Host not found'});
|
||||
}
|
||||
|
||||
const isOnline = await tcpPing(host.ip, host.port, 5000);
|
||||
const now = new Date().toISOString();
|
||||
const statusEntry: StatusEntry = {status: isOnline ? 'online' : 'offline', lastChecked: now};
|
||||
|
||||
hostStatuses.set(id, statusEntry);
|
||||
res.json(statusEntry);
|
||||
} catch (err) {
|
||||
logger.error('Failed to check host status', err);
|
||||
res.status(500).json({error: 'Failed to check host status'});
|
||||
}
|
||||
});
|
||||
|
||||
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 sshIconSymbol = '🖥️';
|
||||
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
||||
@@ -30,16 +33,22 @@ const logger = {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
wss.on('connection', (ws: WebSocket) => {
|
||||
let sshConn: Client | null = null;
|
||||
let sshStream: ClientChannel | null = null;
|
||||
let pingInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
|
||||
|
||||
ws.on('close', () => {
|
||||
cleanupSSH();
|
||||
});
|
||||
|
||||
ws.on('message', (msg: RawData) => {
|
||||
|
||||
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = JSON.parse(msg.toString());
|
||||
@@ -128,38 +137,17 @@ wss.on('connection', (ws: WebSocket) => {
|
||||
ws.send(JSON.stringify({type: 'error', message: 'SSH connection timeout'}));
|
||||
cleanupSSH(connectionTimeout);
|
||||
}
|
||||
}, 15000);
|
||||
}, 60000);
|
||||
|
||||
sshConn.on('ready', () => {
|
||||
clearTimeout(connectionTimeout);
|
||||
const pseudoTtyOpts: PseudoTtyOptions = {
|
||||
term: 'xterm-256color',
|
||||
cols,
|
||||
rows,
|
||||
modes: {
|
||||
ECHO: 1,
|
||||
ECHOCTL: 0,
|
||||
ICANON: 1,
|
||||
ISIG: 1,
|
||||
ICRNL: 1,
|
||||
IXON: 1,
|
||||
IXOFF: 0,
|
||||
ISTRIP: 0,
|
||||
OPOST: 1,
|
||||
ONLCR: 1,
|
||||
OCRNL: 0,
|
||||
ONOCR: 0,
|
||||
ONLRET: 0,
|
||||
CS7: 0,
|
||||
CS8: 1,
|
||||
PARENB: 0,
|
||||
PARODD: 0,
|
||||
TTY_OP_ISPEED: 38400,
|
||||
TTY_OP_OSPEED: 38400,
|
||||
}
|
||||
};
|
||||
|
||||
sshConn!.shell(pseudoTtyOpts, (err, stream) => {
|
||||
|
||||
sshConn!.shell({
|
||||
rows: data.rows,
|
||||
cols: data.cols,
|
||||
term: 'xterm-256color'
|
||||
} as PseudoTtyOptions, (err, stream) => {
|
||||
if (err) {
|
||||
logger.error('Shell error: ' + err.message);
|
||||
ws.send(JSON.stringify({type: 'error', message: 'Shell error: ' + err.message}));
|
||||
@@ -168,34 +156,18 @@ wss.on('connection', (ws: WebSocket) => {
|
||||
|
||||
sshStream = stream;
|
||||
|
||||
stream.on('data', (chunk: Buffer) => {
|
||||
let data: string;
|
||||
try {
|
||||
data = chunk.toString('utf8');
|
||||
} catch (e) {
|
||||
data = chunk.toString('binary');
|
||||
}
|
||||
|
||||
ws.send(JSON.stringify({type: 'data', data}));
|
||||
stream.on('data', (data: Buffer) => {
|
||||
ws.send(JSON.stringify({type: 'data', data: data.toString()}));
|
||||
});
|
||||
|
||||
stream.on('close', () => {
|
||||
cleanupSSH(connectionTimeout);
|
||||
|
||||
ws.send(JSON.stringify({type: 'disconnected', message: 'Connection lost'}));
|
||||
});
|
||||
|
||||
stream.on('error', (err: Error) => {
|
||||
logger.error('SSH stream error: ' + err.message);
|
||||
|
||||
const isConnectionError = err.message.includes('ECONNRESET') ||
|
||||
err.message.includes('EPIPE') ||
|
||||
err.message.includes('ENOTCONN') ||
|
||||
err.message.includes('ETIMEDOUT');
|
||||
|
||||
if (isConnectionError) {
|
||||
ws.send(JSON.stringify({type: 'disconnected', message: 'Connection lost'}));
|
||||
} else {
|
||||
ws.send(JSON.stringify({type: 'error', message: 'SSH stream error: ' + err.message}));
|
||||
}
|
||||
ws.send(JSON.stringify({type: 'error', message: 'SSH stream error: ' + err.message}));
|
||||
});
|
||||
|
||||
setupPingInterval();
|
||||
@@ -233,18 +205,22 @@ wss.on('connection', (ws: WebSocket) => {
|
||||
|
||||
sshConn.on('close', () => {
|
||||
clearTimeout(connectionTimeout);
|
||||
|
||||
cleanupSSH(connectionTimeout);
|
||||
});
|
||||
|
||||
|
||||
|
||||
const connectConfig: any = {
|
||||
host: ip,
|
||||
port,
|
||||
username,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
readyTimeout: 10000,
|
||||
readyTimeout: 60000,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
|
||||
env: {
|
||||
TERM: 'xterm-256color',
|
||||
LANG: 'en_US.UTF-8',
|
||||
@@ -294,12 +270,26 @@ wss.on('connection', (ws: WebSocket) => {
|
||||
}
|
||||
};
|
||||
if (authType === 'key' && key) {
|
||||
connectConfig.privateKey = key;
|
||||
if (keyPassword) {
|
||||
connectConfig.passphrase = keyPassword;
|
||||
}
|
||||
if (keyType && keyType !== 'auto') {
|
||||
connectConfig.privateKeyType = keyType;
|
||||
try {
|
||||
if (!key.includes('-----BEGIN') || !key.includes('-----END')) {
|
||||
throw new Error('Invalid private key format');
|
||||
}
|
||||
|
||||
const cleanKey = key.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
|
||||
connectConfig.privateKey = Buffer.from(cleanKey, 'utf8');
|
||||
|
||||
if (keyPassword) {
|
||||
connectConfig.passphrase = keyPassword;
|
||||
}
|
||||
|
||||
if (keyType && keyType !== 'auto') {
|
||||
connectConfig.privateKeyType = keyType;
|
||||
}
|
||||
} catch (keyError) {
|
||||
logger.error('SSH key format error: ' + keyError.message);
|
||||
ws.send(JSON.stringify({type: 'error', message: 'SSH key format error: Invalid private key format'}));
|
||||
return;
|
||||
}
|
||||
} else if (authType === 'key') {
|
||||
logger.error('SSH key authentication requested but no key provided');
|
||||
@@ -360,4 +350,6 @@ wss.on('connection', (ws: WebSocket) => {
|
||||
}
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
|
||||
});
|
||||
@@ -78,7 +78,7 @@ interface SSHHost {
|
||||
keyType?: string;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableConfigEditor: boolean;
|
||||
enableFileManager: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: TunnelConnection[];
|
||||
createdAt: string;
|
||||
@@ -197,7 +197,8 @@ function classifyError(errorMessage: string): ErrorType {
|
||||
|
||||
if (message.includes("connect etimedout") ||
|
||||
message.includes("timeout") ||
|
||||
message.includes("timed out")) {
|
||||
message.includes("timed out") ||
|
||||
message.includes("keepalive timeout")) {
|
||||
return ERROR_TYPES.TIMEOUT;
|
||||
}
|
||||
|
||||
@@ -267,7 +268,8 @@ function cleanupTunnelResources(tunnelName: string): void {
|
||||
tunnelName,
|
||||
`${tunnelName}_confirm`,
|
||||
`${tunnelName}_retry`,
|
||||
`${tunnelName}_verify_retry`
|
||||
`${tunnelName}_verify_retry`,
|
||||
`${tunnelName}_ping`
|
||||
];
|
||||
|
||||
timerKeys.forEach(key => {
|
||||
@@ -302,7 +304,7 @@ function resetRetryState(tunnelName: string): void {
|
||||
countdownIntervals.delete(tunnelName);
|
||||
}
|
||||
|
||||
['', '_confirm', '_retry', '_verify_retry'].forEach(suffix => {
|
||||
['', '_confirm', '_retry', '_verify_retry', '_ping'].forEach(suffix => {
|
||||
const timerKey = `${tunnelName}${suffix}`;
|
||||
if (verificationTimers.has(timerKey)) {
|
||||
clearTimeout(verificationTimers.get(timerKey)!);
|
||||
@@ -353,7 +355,8 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
|
||||
const maxRetries = tunnelConfig.maxRetries || 3;
|
||||
const retryInterval = tunnelConfig.retryInterval || 5000;
|
||||
|
||||
let retryCount = (retryCounters.get(tunnelName) || 0) + 1;
|
||||
let retryCount = retryCounters.get(tunnelName) || 0;
|
||||
retryCount = retryCount + 1;
|
||||
|
||||
if (retryCount > maxRetries) {
|
||||
logger.error(`All ${maxRetries} retries failed for ${tunnelName}`);
|
||||
@@ -420,7 +423,6 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
|
||||
|
||||
if (!manualDisconnects.has(tunnelName)) {
|
||||
activeTunnels.delete(tunnelName);
|
||||
|
||||
connectSSHTunnel(tunnelConfig, retryCount);
|
||||
}
|
||||
}, retryInterval);
|
||||
@@ -438,264 +440,43 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
|
||||
}
|
||||
|
||||
function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig, isPeriodic = false): void {
|
||||
if (manualDisconnects.has(tunnelName) || !activeTunnels.has(tunnelName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (tunnelVerifications.has(tunnelName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const conn = activeTunnels.get(tunnelName);
|
||||
if (!conn) return;
|
||||
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.VERIFYING
|
||||
});
|
||||
|
||||
const verificationConn = new Client();
|
||||
tunnelVerifications.set(tunnelName, {
|
||||
conn: verificationConn,
|
||||
timeout: setTimeout(() => {
|
||||
logger.error(`Verification timeout for '${tunnelName}'`);
|
||||
cleanupVerification(false, "Verification timeout");
|
||||
}, 10000)
|
||||
});
|
||||
|
||||
function cleanupVerification(isSuccessful: boolean, failureReason = "Unknown verification failure") {
|
||||
const verification = tunnelVerifications.get(tunnelName);
|
||||
if (verification) {
|
||||
clearTimeout(verification.timeout);
|
||||
try {
|
||||
verification.conn.end();
|
||||
} catch (e) {
|
||||
}
|
||||
tunnelVerifications.delete(tunnelName);
|
||||
}
|
||||
|
||||
if (isSuccessful) {
|
||||
if (isPeriodic) {
|
||||
if (!activeTunnels.has(tunnelName)) {
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: true,
|
||||
status: CONNECTION_STATES.CONNECTED
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.DISCONNECTED,
|
||||
reason: 'Tunnel connection lost'
|
||||
});
|
||||
|
||||
if (!isPeriodic) {
|
||||
setupPingInterval(tunnelName, tunnelConfig);
|
||||
}
|
||||
} else {
|
||||
logger.warn(`Verification failed for '${tunnelName}': ${failureReason}`);
|
||||
|
||||
if (failureReason.includes('command failed') || failureReason.includes('connection error') || failureReason.includes('timeout')) {
|
||||
if (!manualDisconnects.has(tunnelName)) {
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.FAILED,
|
||||
reason: failureReason
|
||||
});
|
||||
}
|
||||
activeTunnels.delete(tunnelName);
|
||||
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
|
||||
} else {
|
||||
logger.info(`Assuming tunnel '${tunnelName}' is working despite verification warning: ${failureReason}`);
|
||||
cleanupVerification(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function attemptVerification() {
|
||||
const testCmd = `timeout 3 bash -c 'nc -z ${tunnelConfig.endpointIP} ${tunnelConfig.endpointPort}'`;
|
||||
|
||||
verificationConn.exec(testCmd, (err, stream) => {
|
||||
if (err) {
|
||||
logger.error(`Verification command failed for '${tunnelName}': ${err.message}`);
|
||||
cleanupVerification(false, `Verification command failed: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let output = '';
|
||||
let errorOutput = '';
|
||||
|
||||
stream.on('data', (data: Buffer) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
stream.stderr?.on('data', (data: Buffer) => {
|
||||
errorOutput += data.toString();
|
||||
});
|
||||
|
||||
stream.on('close', (code: number) => {
|
||||
if (code === 0) {
|
||||
cleanupVerification(true);
|
||||
} else {
|
||||
const isTimeout = errorOutput.includes('timeout') || errorOutput.includes('Connection timed out');
|
||||
const isConnectionRefused = errorOutput.includes('Connection refused') || errorOutput.includes('No route to host');
|
||||
|
||||
let failureReason = `Cannot connect to ${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort}`;
|
||||
if (isTimeout) {
|
||||
failureReason = `Tunnel verification timeout - cannot reach ${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort}`;
|
||||
} else if (isConnectionRefused) {
|
||||
failureReason = `Connection refused to ${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort} - tunnel may not be established`;
|
||||
}
|
||||
|
||||
cleanupVerification(false, failureReason);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('error', (err: Error) => {
|
||||
logger.error(`Verification stream error for '${tunnelName}': ${err.message}`);
|
||||
cleanupVerification(false, `Verification stream error: ${err.message}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
verificationConn.on('ready', () => {
|
||||
setTimeout(() => {
|
||||
attemptVerification();
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
verificationConn.on('error', (err: Error) => {
|
||||
cleanupVerification(false, `Verification connection error: ${err.message}`);
|
||||
});
|
||||
|
||||
verificationConn.on('close', () => {
|
||||
if (tunnelVerifications.has(tunnelName)) {
|
||||
cleanupVerification(false, "Verification connection closed");
|
||||
}
|
||||
});
|
||||
|
||||
const connOptions: any = {
|
||||
host: tunnelConfig.sourceIP,
|
||||
port: tunnelConfig.sourceSSHPort,
|
||||
username: tunnelConfig.sourceUsername,
|
||||
readyTimeout: 10000,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
algorithms: {
|
||||
kex: [
|
||||
'diffie-hellman-group14-sha256',
|
||||
'diffie-hellman-group14-sha1',
|
||||
'diffie-hellman-group1-sha1',
|
||||
'diffie-hellman-group-exchange-sha256',
|
||||
'diffie-hellman-group-exchange-sha1',
|
||||
'ecdh-sha2-nistp256',
|
||||
'ecdh-sha2-nistp384',
|
||||
'ecdh-sha2-nistp521'
|
||||
],
|
||||
cipher: [
|
||||
'aes128-ctr',
|
||||
'aes192-ctr',
|
||||
'aes256-ctr',
|
||||
'aes128-gcm@openssh.com',
|
||||
'aes256-gcm@openssh.com',
|
||||
'aes128-cbc',
|
||||
'aes192-cbc',
|
||||
'aes256-cbc',
|
||||
'3des-cbc'
|
||||
],
|
||||
hmac: [
|
||||
'hmac-sha2-256',
|
||||
'hmac-sha2-512',
|
||||
'hmac-sha1',
|
||||
'hmac-md5'
|
||||
],
|
||||
compress: [
|
||||
'none',
|
||||
'zlib@openssh.com',
|
||||
'zlib'
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
|
||||
connOptions.privateKey = tunnelConfig.sourceSSHKey;
|
||||
if (tunnelConfig.sourceKeyPassword) {
|
||||
connOptions.passphrase = tunnelConfig.sourceKeyPassword;
|
||||
}
|
||||
if (tunnelConfig.sourceKeyType && tunnelConfig.sourceKeyType !== 'auto') {
|
||||
connOptions.privateKeyType = tunnelConfig.sourceKeyType;
|
||||
}
|
||||
} else if (tunnelConfig.sourceAuthMethod === "key") {
|
||||
logger.error(`SSH key authentication requested but no key provided for tunnel '${tunnelName}'`);
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.FAILED,
|
||||
reason: "SSH key authentication requested but no key provided"
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
connOptions.password = tunnelConfig.sourcePassword;
|
||||
}
|
||||
|
||||
verificationConn.connect(connOptions);
|
||||
}
|
||||
|
||||
function setupPingInterval(tunnelName: string, tunnelConfig: TunnelConfig): void {
|
||||
const pingKey = `${tunnelName}_ping`;
|
||||
if (verificationTimers.has(pingKey)) {
|
||||
clearInterval(verificationTimers.get(pingKey)!);
|
||||
verificationTimers.delete(pingKey);
|
||||
}
|
||||
|
||||
const pingInterval = setInterval(() => {
|
||||
if (!activeTunnels.has(tunnelName) || manualDisconnects.has(tunnelName)) {
|
||||
clearInterval(pingInterval);
|
||||
return;
|
||||
}
|
||||
|
||||
const conn = activeTunnels.get(tunnelName);
|
||||
if (!conn) {
|
||||
clearInterval(pingInterval);
|
||||
return;
|
||||
}
|
||||
|
||||
conn.exec('echo "ping"', (err, stream) => {
|
||||
if (err) {
|
||||
const currentStatus = connectionStatus.get(tunnelName);
|
||||
if (currentStatus?.status === CONNECTION_STATES.CONNECTED) {
|
||||
if (!activeTunnels.has(tunnelName)) {
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.DISCONNECTED,
|
||||
reason: 'Tunnel connection lost'
|
||||
});
|
||||
clearInterval(pingInterval);
|
||||
|
||||
if (!manualDisconnects.has(tunnelName)) {
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.UNSTABLE,
|
||||
reason: "Ping failed"
|
||||
});
|
||||
}
|
||||
|
||||
activeTunnels.delete(tunnelName);
|
||||
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
|
||||
return;
|
||||
verificationTimers.delete(pingKey);
|
||||
}
|
||||
|
||||
stream.on('close', (code: number) => {
|
||||
if (code !== 0) {
|
||||
clearInterval(pingInterval);
|
||||
|
||||
if (!manualDisconnects.has(tunnelName)) {
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.UNSTABLE,
|
||||
reason: "Ping command failed"
|
||||
});
|
||||
}
|
||||
|
||||
activeTunnels.delete(tunnelName);
|
||||
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('error', (err: Error) => {
|
||||
clearInterval(pingInterval);
|
||||
|
||||
if (!manualDisconnects.has(tunnelName)) {
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.UNSTABLE,
|
||||
reason: "Ping stream error"
|
||||
});
|
||||
}
|
||||
|
||||
activeTunnels.delete(tunnelName);
|
||||
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
|
||||
});
|
||||
});
|
||||
}, 60000);
|
||||
} else {
|
||||
clearInterval(pingInterval);
|
||||
verificationTimers.delete(pingKey);
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
verificationTimers.set(pingKey, pingInterval);
|
||||
}
|
||||
|
||||
function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
@@ -751,7 +532,7 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
|
||||
}
|
||||
}
|
||||
}, 15000);
|
||||
}, 60000);
|
||||
|
||||
conn.on("error", (err) => {
|
||||
clearTimeout(connectionTimeout);
|
||||
@@ -778,6 +559,8 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
errorType === ERROR_TYPES.PORT ||
|
||||
errorType === ERROR_TYPES.PERMISSION ||
|
||||
manualDisconnects.has(tunnelName);
|
||||
|
||||
|
||||
|
||||
handleDisconnect(tunnelName, tunnelConfig, !shouldNotRetry);
|
||||
});
|
||||
@@ -841,7 +624,11 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
|
||||
setTimeout(() => {
|
||||
if (!manualDisconnects.has(tunnelName) && activeTunnels.has(tunnelName)) {
|
||||
verifyTunnelConnection(tunnelName, tunnelConfig, false);
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: true,
|
||||
status: CONNECTION_STATES.CONNECTED
|
||||
});
|
||||
setupPingInterval(tunnelName, tunnelConfig);
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
@@ -901,7 +688,6 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
|
||||
stream.stderr.on("data", (data) => {
|
||||
const errorMsg = data.toString().trim();
|
||||
logger.debug(`Tunnel stderr for '${tunnelName}': ${errorMsg}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -910,11 +696,11 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
host: tunnelConfig.sourceIP,
|
||||
port: tunnelConfig.sourceSSHPort,
|
||||
username: tunnelConfig.sourceUsername,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
readyTimeout: 10000,
|
||||
readyTimeout: 60000,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
tcpKeepAliveInitialDelay: 15000,
|
||||
algorithms: {
|
||||
kex: [
|
||||
'diffie-hellman-group14-sha256',
|
||||
@@ -952,8 +738,8 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
};
|
||||
|
||||
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
|
||||
if (!tunnelConfig.sourceSSHKey.includes('-----BEGIN')) {
|
||||
logger.error(`Invalid SSH key format for tunnel '${tunnelName}'. Key should start with '-----BEGIN'`);
|
||||
if (!tunnelConfig.sourceSSHKey.includes('-----BEGIN') || !tunnelConfig.sourceSSHKey.includes('-----END')) {
|
||||
logger.error(`Invalid SSH key format for tunnel '${tunnelName}'. Key should contain both BEGIN and END markers`);
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.FAILED,
|
||||
@@ -962,7 +748,8 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
return;
|
||||
}
|
||||
|
||||
connOptions.privateKey = tunnelConfig.sourceSSHKey;
|
||||
const cleanKey = tunnelConfig.sourceSSHKey.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
connOptions.privateKey = Buffer.from(cleanKey, 'utf8');
|
||||
if (tunnelConfig.sourceKeyPassword) {
|
||||
connOptions.passphrase = tunnelConfig.sourceKeyPassword;
|
||||
}
|
||||
@@ -981,43 +768,16 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
connOptions.password = tunnelConfig.sourcePassword;
|
||||
}
|
||||
|
||||
const testSocket = new net.Socket();
|
||||
testSocket.setTimeout(5000);
|
||||
|
||||
testSocket.on('connect', () => {
|
||||
testSocket.destroy();
|
||||
|
||||
const currentStatus = connectionStatus.get(tunnelName);
|
||||
if (!currentStatus || currentStatus.status !== CONNECTION_STATES.WAITING) {
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.CONNECTING,
|
||||
retryCount: retryAttempt > 0 ? retryAttempt : undefined
|
||||
});
|
||||
}
|
||||
|
||||
conn.connect(connOptions);
|
||||
});
|
||||
|
||||
testSocket.on('timeout', () => {
|
||||
testSocket.destroy();
|
||||
const finalStatus = connectionStatus.get(tunnelName);
|
||||
if (!finalStatus || finalStatus.status !== CONNECTION_STATES.WAITING) {
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.FAILED,
|
||||
reason: "Network connectivity test failed - server not reachable"
|
||||
status: CONNECTION_STATES.CONNECTING,
|
||||
retryCount: retryAttempt > 0 ? retryAttempt : undefined
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
testSocket.on('error', (err: any) => {
|
||||
testSocket.destroy();
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.FAILED,
|
||||
reason: `Network connectivity test failed - ${err.message}`
|
||||
});
|
||||
});
|
||||
|
||||
testSocket.connect(tunnelConfig.sourceSSHPort, tunnelConfig.sourceIP);
|
||||
conn.connect(connOptions);
|
||||
}
|
||||
|
||||
function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string, callback: (err?: Error) => void) {
|
||||
@@ -1029,9 +789,9 @@ function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string
|
||||
username: tunnelConfig.sourceUsername,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
readyTimeout: 10000,
|
||||
readyTimeout: 60000,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
tcpKeepAliveInitialDelay: 15000,
|
||||
algorithms: {
|
||||
kex: [
|
||||
'diffie-hellman-group14-sha256',
|
||||
@@ -1068,7 +828,13 @@ function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string
|
||||
}
|
||||
};
|
||||
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
|
||||
connOptions.privateKey = tunnelConfig.sourceSSHKey;
|
||||
if (!tunnelConfig.sourceSSHKey.includes('-----BEGIN') || !tunnelConfig.sourceSSHKey.includes('-----END')) {
|
||||
callback(new Error('Invalid SSH key format'));
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanKey = tunnelConfig.sourceSSHKey.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
connOptions.privateKey = Buffer.from(cleanKey, 'utf8');
|
||||
if (tunnelConfig.sourceKeyPassword) {
|
||||
connOptions.passphrase = tunnelConfig.sourceKeyPassword;
|
||||
}
|
||||
@@ -2,9 +2,10 @@
|
||||
// node ./dist/backend/starter.js
|
||||
|
||||
import './database/database.js'
|
||||
import './ssh/ssh.js';
|
||||
import './ssh_tunnel/ssh_tunnel.js';
|
||||
import './config_editor/config_editor.js';
|
||||
import './ssh/terminal.js';
|
||||
import './ssh/tunnel.js';
|
||||
import './ssh/file-manager.js';
|
||||
import './ssh/server-stats.js';
|
||||
import chalk from 'chalk';
|
||||
|
||||
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
|
||||
data-slot="sheet-overlay"
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@@ -56,7 +56,7 @@ function SheetContent({
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
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" &&
|
||||
"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" &&
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
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 { useIsMobile } from "@/hooks/use-mobile"
|
||||
@@ -137,7 +137,7 @@ function SidebarProvider({
|
||||
} as React.CSSProperties
|
||||
}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@@ -242,7 +242,7 @@ function Sidebar({
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
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}
|
||||
</div>
|
||||
@@ -720,6 +720,5 @@ export {
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
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}
|
||||
>
|
||||
{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.Portal>
|
||||
)
|
||||
|
||||
@@ -130,4 +130,48 @@
|
||||
body {
|
||||
@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;
|
||||
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 {ConfigEditorSidebar} from "@/apps/SSH/Config Editor/ConfigEditorSidebar.tsx";
|
||||
import {ConfigTabList} from "@/apps/SSH/Config Editor/ConfigTabList.tsx";
|
||||
import {ConfigHomeView} from "@/apps/SSH/Config Editor/ConfigHomeView.tsx";
|
||||
import {ConfigCodeEditor} from "@/apps/SSH/Config Editor/ConfigCodeEditor.tsx";
|
||||
import {FileManagerLeftSidebar} from "@/ui/Apps/File Manager/FileManagerLeftSidebar.tsx";
|
||||
import {FileManagerTabList} from "@/ui/Apps/File Manager/FileManagerTabList.tsx";
|
||||
import {FileManagerHomeView} from "@/ui/Apps/File Manager/FileManagerHomeView.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 {ConfigTopbar} from "@/apps/SSH/Config Editor/ConfigTopbar.tsx";
|
||||
import {FIleManagerTopNavbar} from "@/ui/Apps/File Manager/FIleManagerTopNavbar.tsx";
|
||||
import {cn} from '@/lib/utils.ts';
|
||||
import {Save, RefreshCw, Settings, Trash2} from 'lucide-react';
|
||||
import {Separator} from '@/components/ui/separator.tsx';
|
||||
import {toast} from 'sonner';
|
||||
import {
|
||||
getConfigEditorRecent,
|
||||
getConfigEditorPinned,
|
||||
getConfigEditorShortcuts,
|
||||
addConfigEditorRecent,
|
||||
removeConfigEditorRecent,
|
||||
addConfigEditorPinned,
|
||||
removeConfigEditorPinned,
|
||||
addConfigEditorShortcut,
|
||||
removeConfigEditorShortcut,
|
||||
getFileManagerRecent,
|
||||
getFileManagerPinned,
|
||||
getFileManagerShortcuts,
|
||||
addFileManagerRecent,
|
||||
removeFileManagerRecent,
|
||||
addFileManagerPinned,
|
||||
removeFileManagerPinned,
|
||||
addFileManagerShortcut,
|
||||
removeFileManagerShortcut,
|
||||
readSSHFile,
|
||||
writeSSHFile,
|
||||
getSSHStatus,
|
||||
connectSSH
|
||||
} from '@/apps/SSH/ssh-axios.ts';
|
||||
} from '@/ui/main-axios.ts';
|
||||
|
||||
interface Tab {
|
||||
id: string | number;
|
||||
@@ -31,8 +35,6 @@ interface Tab {
|
||||
sshSessionId?: string;
|
||||
filePath?: string;
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
success?: string;
|
||||
dirty?: boolean;
|
||||
}
|
||||
|
||||
@@ -52,14 +54,18 @@ interface SSHHost {
|
||||
keyType?: string;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableConfigEditor: boolean;
|
||||
enableFileManager: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: any[];
|
||||
createdAt: 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 [activeTab, setActiveTab] = useState<string | number>('home');
|
||||
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 [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);
|
||||
|
||||
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(() => {
|
||||
if (currentHost) {
|
||||
fetchHomeData();
|
||||
@@ -102,16 +128,16 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
|
||||
|
||||
try {
|
||||
const homeDataPromise = Promise.all([
|
||||
getConfigEditorRecent(currentHost.id),
|
||||
getConfigEditorPinned(currentHost.id),
|
||||
getConfigEditorShortcuts(currentHost.id),
|
||||
getFileManagerRecent(currentHost.id),
|
||||
getFileManagerPinned(currentHost.id),
|
||||
getFileManagerShortcuts(currentHost.id),
|
||||
]);
|
||||
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
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 => ({
|
||||
...file,
|
||||
@@ -181,7 +207,7 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
|
||||
loading: false,
|
||||
error: undefined
|
||||
} : t));
|
||||
await addConfigEditorRecent({
|
||||
await addFileManagerRecent({
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
isSSH: true,
|
||||
@@ -191,7 +217,8 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
|
||||
fetchHomeData();
|
||||
} catch (err: any) {
|
||||
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);
|
||||
@@ -199,7 +226,7 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
|
||||
|
||||
const handleRemoveRecent = async (file: any) => {
|
||||
try {
|
||||
await removeConfigEditorRecent({
|
||||
await removeFileManagerRecent({
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
isSSH: true,
|
||||
@@ -213,7 +240,7 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
|
||||
|
||||
const handlePinFile = async (file: any) => {
|
||||
try {
|
||||
await addConfigEditorPinned({
|
||||
await addFileManagerPinned({
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
isSSH: true,
|
||||
@@ -230,7 +257,7 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
|
||||
|
||||
const handleUnpinFile = async (file: any) => {
|
||||
try {
|
||||
await removeConfigEditorPinned({
|
||||
await removeFileManagerPinned({
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
isSSH: true,
|
||||
@@ -270,7 +297,7 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
|
||||
const handleAddShortcut = async (folderPath: string) => {
|
||||
try {
|
||||
const name = folderPath.split('/').pop() || folderPath;
|
||||
await addConfigEditorShortcut({
|
||||
await addFileManagerShortcut({
|
||||
name,
|
||||
path: folderPath,
|
||||
isSSH: true,
|
||||
@@ -284,7 +311,7 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
|
||||
|
||||
const handleRemoveShortcut = async (shortcut: any) => {
|
||||
try {
|
||||
await removeConfigEditorShortcut({
|
||||
await removeFileManagerShortcut({
|
||||
name: shortcut.name,
|
||||
path: shortcut.path,
|
||||
isSSH: true,
|
||||
@@ -345,7 +372,7 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
|
||||
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) {
|
||||
const connectPromise = connectSSH(tab.sshSessionId, {
|
||||
@@ -375,18 +402,15 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
|
||||
const result = await Promise.race([savePromise, timeoutPromise]);
|
||||
setTabs(tabs => tabs.map(t => t.id === tab.id ? {
|
||||
...t,
|
||||
dirty: false,
|
||||
success: 'File saved successfully'
|
||||
loading: false
|
||||
} : t));
|
||||
|
||||
setTimeout(() => {
|
||||
setTabs(tabs => tabs.map(t => t.id === tab.id ? {...t, success: undefined} : t));
|
||||
}, 3000);
|
||||
toast.success('File saved successfully');
|
||||
|
||||
Promise.allSettled([
|
||||
(async () => {
|
||||
try {
|
||||
await addConfigEditorRecent({
|
||||
await addFileManagerRecent({
|
||||
name: tab.fileName,
|
||||
path: tab.filePath,
|
||||
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.`;
|
||||
}
|
||||
|
||||
setTabs(tabs => {
|
||||
const updatedTabs = tabs.map(t => t.id === tab.id ? {
|
||||
...t,
|
||||
error: `Failed to save file: ${errorMessage}`
|
||||
} : t);
|
||||
return updatedTabs;
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
setTabs(currentTabs => [...currentTabs]);
|
||||
}, 100);
|
||||
toast.error(`Failed to save file: ${errorMessage}`);
|
||||
setTabs(tabs => tabs.map(t => t.id === tab.id ? {
|
||||
...t,
|
||||
loading: false
|
||||
} : t));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleHostChange = (host: SSHHost | null) => {
|
||||
setCurrentHost(host);
|
||||
setTabs([]);
|
||||
setActiveTab('home');
|
||||
const handleHostChange = (_host: SSHHost | null) => {
|
||||
};
|
||||
|
||||
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) {
|
||||
return (
|
||||
<div style={{position: 'relative', width: '100vw', height: '100vh', overflow: 'hidden'}}>
|
||||
<div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100vh', zIndex: 20}}>
|
||||
<ConfigEditorSidebar
|
||||
onSelectView={onSelectView}
|
||||
<div style={{position: 'absolute', inset: 0, overflow: 'hidden'}} className="rounded-md">
|
||||
<div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100%', zIndex: 20}}>
|
||||
<FileManagerLeftSidebar
|
||||
onSelectView={onSelectView || (() => {
|
||||
})}
|
||||
onOpenFile={handleOpenFile}
|
||||
tabs={tabs}
|
||||
ref={sidebarRef}
|
||||
onHostChange={handleHostChange}
|
||||
host={initialHost as SSHHost}
|
||||
onOperationComplete={handleOperationComplete}
|
||||
onError={handleError}
|
||||
onSuccess={handleSuccess}
|
||||
onPathChange={updateCurrentPath}
|
||||
/>
|
||||
</div>
|
||||
<div style={{
|
||||
@@ -467,55 +526,67 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{position: 'relative', width: '100vw', height: '100vh', overflow: 'hidden'}}>
|
||||
<div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100vh', zIndex: 20}}>
|
||||
<ConfigEditorSidebar
|
||||
onSelectView={onSelectView}
|
||||
<div style={{position: 'absolute', inset: 0, overflow: 'hidden'}} className="rounded-md">
|
||||
<div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100%', zIndex: 20}}>
|
||||
<FileManagerLeftSidebar
|
||||
onSelectView={onSelectView || (() => {
|
||||
})}
|
||||
onOpenFile={handleOpenFile}
|
||||
tabs={tabs}
|
||||
ref={sidebarRef}
|
||||
onHostChange={handleHostChange}
|
||||
host={currentHost as SSHHost}
|
||||
onOperationComplete={handleOperationComplete}
|
||||
onError={handleError}
|
||||
onSuccess={handleSuccess}
|
||||
onPathChange={updateCurrentPath}
|
||||
onDeleteItem={handleDeleteFromSidebar}
|
||||
/>
|
||||
</div>
|
||||
<div style={{position: 'absolute', top: 0, left: 256, right: 0, height: 44, zIndex: 30}}>
|
||||
<div className="flex items-center w-full bg-[#18181b] border-b border-[#222224] h-11 relative px-4"
|
||||
style={{height: 44}}>
|
||||
{/* Tab list scrollable area */}
|
||||
<div className="flex-1 min-w-0 h-full flex items-center">
|
||||
<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"
|
||||
style={{minWidth: 0}}>
|
||||
<ConfigTopbar
|
||||
tabs={tabs.map(t => ({id: t.id, title: t.title}))}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
closeTab={closeTab}
|
||||
onHomeClick={() => {
|
||||
setActiveTab('home');
|
||||
if (currentHost) {
|
||||
fetchHomeData();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<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-2 border-[#303032] h-[50px] relative">
|
||||
<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">
|
||||
<FIleManagerTopNavbar
|
||||
tabs={tabs.map(t => ({id: t.id, title: t.title}))}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
closeTab={closeTab}
|
||||
onHomeClick={() => {
|
||||
setActiveTab('home');
|
||||
if (currentHost) {
|
||||
fetchHomeData();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2 flex-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowOperations(!showOperations)}
|
||||
className={cn(
|
||||
'w-[30px] h-[30px]',
|
||||
showOperations ? 'bg-[#2d2d30] border-[#434345]' : ''
|
||||
)}
|
||||
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={() => {
|
||||
const tab = tabs.find(t => t.id === activeTab);
|
||||
if (tab && !isSaving) handleSave(tab);
|
||||
}}
|
||||
disabled={activeTab === 'home' || !tabs.find(t => t.id === activeTab)?.dirty || isSaving}
|
||||
className={cn(
|
||||
'w-[30px] h-[30px]',
|
||||
activeTab === 'home' || !tabs.find(t => t.id === activeTab)?.dirty || isSaving ? 'opacity-60 cursor-not-allowed' : ''
|
||||
)}
|
||||
>
|
||||
{isSaving ? <RefreshCw className="h-4 w-4 animate-spin"/> : <Save className="h-4 w-4"/>}
|
||||
</Button>
|
||||
</div>
|
||||
{/* Save button - always visible */}
|
||||
<Button
|
||||
className={cn(
|
||||
'ml-4 px-4 py-1.5 border rounded-md text-sm font-medium transition-colors',
|
||||
'border-[#2d2d30] text-white bg-transparent hover:bg-[#23232a] active:bg-[#23232a] focus:bg-[#23232a]',
|
||||
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}
|
||||
onClick={() => {
|
||||
const tab = tabs.find(t => t.id === activeTab);
|
||||
if (tab && !isSaving) handleSave(tab);
|
||||
}}
|
||||
type="button"
|
||||
style={{height: 36, alignSelf: 'center'}}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
@@ -530,79 +601,90 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
{activeTab === 'home' ? (
|
||||
<ConfigHomeView
|
||||
recent={recent}
|
||||
pinned={pinned}
|
||||
shortcuts={shortcuts}
|
||||
onOpenFile={handleOpenFile}
|
||||
onRemoveRecent={handleRemoveRecent}
|
||||
onPinFile={handlePinFile}
|
||||
onUnpinFile={handleUnpinFile}
|
||||
onOpenShortcut={handleOpenShortcut}
|
||||
onRemoveShortcut={handleRemoveShortcut}
|
||||
onAddShortcut={handleAddShortcut}
|
||||
/>
|
||||
) : (
|
||||
(() => {
|
||||
const tab = tabs.find(t => t.id === activeTab);
|
||||
if (!tab) return null;
|
||||
return (
|
||||
<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 className="flex h-full">
|
||||
<div className="flex-1">
|
||||
{activeTab === 'home' ? (
|
||||
<FileManagerHomeView
|
||||
recent={recent}
|
||||
pinned={pinned}
|
||||
shortcuts={shortcuts}
|
||||
onOpenFile={handleOpenFile}
|
||||
onRemoveRecent={handleRemoveRecent}
|
||||
onPinFile={handlePinFile}
|
||||
onUnpinFile={handleUnpinFile}
|
||||
onOpenShortcut={handleOpenShortcut}
|
||||
onRemoveShortcut={handleRemoveShortcut}
|
||||
onAddShortcut={handleAddShortcut}
|
||||
/>
|
||||
) : (
|
||||
(() => {
|
||||
const tab = tabs.find(t => t.id === activeTab);
|
||||
if (!tab) return null;
|
||||
return (
|
||||
<div className="flex flex-col h-full" style={{flex: 1, minHeight: 0}}>
|
||||
<div className="flex-1 min-h-0">
|
||||
<FileManagerFileEditor
|
||||
content={tab.content}
|
||||
fileName={tab.fileName}
|
||||
onContentChange={content => setTabContent(tab.id, content)}
|
||||
/>
|
||||
</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">
|
||||
<ConfigCodeEditor
|
||||
content={tab.content}
|
||||
fileName={tab.fileName}
|
||||
onContentChange={content => setTabContent(tab.id, content)}
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -5,13 +5,13 @@ import {hyperLink} from '@uiw/codemirror-extensions-hyper-link';
|
||||
import {oneDark} from '@codemirror/theme-one-dark';
|
||||
import {EditorView} from '@codemirror/view';
|
||||
|
||||
interface ConfigCodeEditorProps {
|
||||
interface FileManagerCodeEditorProps {
|
||||
content: string;
|
||||
fileName: string;
|
||||
onContentChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function ConfigCodeEditor({content, fileName, onContentChange}: ConfigCodeEditorProps) {
|
||||
export function FileManagerFileEditor({content, fileName, onContentChange}: FileManagerCodeEditorProps) {
|
||||
function getLanguageName(filename: string): string {
|
||||
if (!filename || typeof filename !== 'string') {
|
||||
return 'text';
|
||||
@@ -18,7 +18,7 @@ interface ShortcutItem {
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface ConfigHomeViewProps {
|
||||
interface FileManagerHomeViewProps {
|
||||
recent: FileItem[];
|
||||
pinned: FileItem[];
|
||||
shortcuts: ShortcutItem[];
|
||||
@@ -31,25 +31,25 @@ interface ConfigHomeViewProps {
|
||||
onAddShortcut: (path: string) => void;
|
||||
}
|
||||
|
||||
export function ConfigHomeView({
|
||||
recent,
|
||||
pinned,
|
||||
shortcuts,
|
||||
onOpenFile,
|
||||
onRemoveRecent,
|
||||
onPinFile,
|
||||
onUnpinFile,
|
||||
onOpenShortcut,
|
||||
onRemoveShortcut,
|
||||
onAddShortcut
|
||||
}: ConfigHomeViewProps) {
|
||||
export function FileManagerHomeView({
|
||||
recent,
|
||||
pinned,
|
||||
shortcuts,
|
||||
onOpenFile,
|
||||
onRemoveRecent,
|
||||
onPinFile,
|
||||
onUnpinFile,
|
||||
onOpenShortcut,
|
||||
onRemoveShortcut,
|
||||
onAddShortcut
|
||||
}: FileManagerHomeViewProps) {
|
||||
const [tab, setTab] = useState<'recent' | 'pinned' | 'shortcuts'>('recent');
|
||||
const [newShortcut, setNewShortcut] = useState('');
|
||||
|
||||
|
||||
const renderFileCard = (file: FileItem, onRemove: () => void, onPin?: () => void, isPinned = false) => (
|
||||
<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
|
||||
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
|
||||
onClick={() => onOpenFile(file)}
|
||||
@@ -92,7 +92,7 @@ export function ConfigHomeView({
|
||||
|
||||
const renderShortcutCard = (shortcut: ShortcutItem) => (
|
||||
<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
|
||||
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
|
||||
onClick={() => onOpenShortcut(shortcut)}
|
||||
@@ -120,7 +120,7 @@ export function ConfigHomeView({
|
||||
return (
|
||||
<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">
|
||||
<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="pinned" className="data-[state=active]:bg-[#23232a]">Pinned</TabsTrigger>
|
||||
<TabsTrigger value="shortcuts" className="data-[state=active]:bg-[#23232a]">Folder
|
||||
@@ -128,7 +128,8 @@ export function ConfigHomeView({
|
||||
</TabsList>
|
||||
|
||||
<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 ? (
|
||||
<div className="flex items-center justify-center py-8 col-span-full">
|
||||
<span className="text-sm text-muted-foreground">No recent files.</span>
|
||||
@@ -145,7 +146,8 @@ export function ConfigHomeView({
|
||||
</TabsContent>
|
||||
|
||||
<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 ? (
|
||||
<div className="flex items-center justify-center py-8 col-span-full">
|
||||
<span className="text-sm text-muted-foreground">No pinned files.</span>
|
||||
@@ -162,12 +164,12 @@ export function ConfigHomeView({
|
||||
</TabsContent>
|
||||
|
||||
<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
|
||||
placeholder="Enter folder path"
|
||||
value={newShortcut}
|
||||
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) => {
|
||||
if (e.key === 'Enter' && newShortcut.trim()) {
|
||||
onAddShortcut(newShortcut.trim());
|
||||
@@ -177,8 +179,8 @@ export function ConfigHomeView({
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 px-2 bg-[#23232a] border-[#434345] hover:bg-[#2d2d30] rounded-md"
|
||||
variant="ghost"
|
||||
className="h-8 px-2 bg-[#23232a] border-2 !border-[#303032] hover:bg-[#2d2d30] rounded-md"
|
||||
onClick={() => {
|
||||
if (newShortcut.trim()) {
|
||||
onAddShortcut(newShortcut.trim());
|
||||
@@ -190,7 +192,8 @@ export function ConfigHomeView({
|
||||
Add
|
||||
</Button>
|
||||
</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 ? (
|
||||
<div className="flex items-center justify-center py-4 col-span-full">
|
||||
<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;
|
||||
}
|
||||
|
||||
interface ConfigFileSidebarViewerProps {
|
||||
interface FileManagerLeftSidebarVileViewerProps {
|
||||
sshConnections: SSHConnection[];
|
||||
onAddSSH: () => void;
|
||||
onConnectSSH: (conn: SSHConnection) => void;
|
||||
@@ -41,70 +41,28 @@ interface ConfigFileSidebarViewerProps {
|
||||
currentSSH?: SSHConnection;
|
||||
}
|
||||
|
||||
export function ConfigFileSidebarViewer({
|
||||
sshConnections,
|
||||
onAddSSH,
|
||||
onConnectSSH,
|
||||
onEditSSH,
|
||||
onDeleteSSH,
|
||||
onPinSSH,
|
||||
currentPath,
|
||||
files,
|
||||
onOpenFile,
|
||||
onOpenFolder,
|
||||
onStarFile,
|
||||
onDeleteFile,
|
||||
isLoading,
|
||||
error,
|
||||
isSSHMode,
|
||||
onSwitchToLocal,
|
||||
onSwitchToSSH,
|
||||
currentSSH,
|
||||
}: ConfigFileSidebarViewerProps) {
|
||||
export function FileManagerLeftSidebarFileViewer({
|
||||
sshConnections,
|
||||
onAddSSH,
|
||||
onConnectSSH,
|
||||
onEditSSH,
|
||||
onDeleteSSH,
|
||||
onPinSSH,
|
||||
currentPath,
|
||||
files,
|
||||
onOpenFile,
|
||||
onOpenFolder,
|
||||
onStarFile,
|
||||
onDeleteFile,
|
||||
isLoading,
|
||||
error,
|
||||
isSSHMode,
|
||||
onSwitchToLocal,
|
||||
onSwitchToSSH,
|
||||
currentSSH,
|
||||
}: FileManagerLeftSidebarVileViewerProps) {
|
||||
return (
|
||||
<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="mb-2 flex items-center gap-2">
|
||||
<span
|
||||
@@ -119,7 +77,7 @@ export function ConfigFileSidebarViewer({
|
||||
<div className="flex flex-col gap-1">
|
||||
{files.map((item) => (
|
||||
<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"
|
||||
onClick={() => item.type === 'directory' ? onOpenFolder(item) : onOpenFile(item)}>
|
||||
{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 {SSHManagerSidebar} from "@/apps/SSH/Manager/SSHManagerSidebar.tsx";
|
||||
import {SSHManagerHostViewer} from "@/apps/SSH/Manager/SSHManagerHostViewer.tsx"
|
||||
import {HostManagerHostViewer} from "@/ui/Apps/Host Manager/HostManagerHostViewer.tsx"
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.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;
|
||||
isTopbarOpen?: boolean;
|
||||
}
|
||||
|
||||
interface SSHHost {
|
||||
@@ -25,16 +26,17 @@ interface SSHHost {
|
||||
keyType?: string;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableConfigEditor: boolean;
|
||||
enableFileManager: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: any[];
|
||||
createdAt: 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 [editingHost, setEditingHost] = useState<SSHHost | null>(null);
|
||||
const {state: sidebarState} = useSidebar();
|
||||
|
||||
const handleEditHost = (host: SSHHost) => {
|
||||
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 (
|
||||
<div>
|
||||
<SSHManagerSidebar
|
||||
onSelectView={onSelectView}
|
||||
/>
|
||||
|
||||
<div className="flex w-screen h-screen overflow-hidden">
|
||||
<div className="w-[256px]"/>
|
||||
|
||||
<div className="w-full">
|
||||
<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}
|
||||
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="add_host">
|
||||
{editingHost ? "Edit Host" : "Add Host"}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="host_viewer" className="flex-1 flex flex-col h-full min-h-0">
|
||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
||||
<SSHManagerHostViewer onEditHost={handleEditHost}/>
|
||||
<Separator className="p-0.25 -mt-0.5 mb-1"/>
|
||||
<HostManagerHostViewer onEditHost={handleEditHost}/>
|
||||
</TabsContent>
|
||||
<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">
|
||||
<SSHManagerHostEditor
|
||||
<HostManagerHostEditor
|
||||
editingHost={editingHost}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
/>
|
||||
@@ -19,7 +19,7 @@ import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx
|
||||
import React, {useEffect, useRef, useState} from "react";
|
||||
import {Switch} from "@/components/ui/switch.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 {
|
||||
id: number;
|
||||
@@ -37,7 +37,7 @@ interface SSHHost {
|
||||
keyType?: string;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableConfigEditor: boolean;
|
||||
enableFileManager: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: any[];
|
||||
createdAt: string;
|
||||
@@ -49,7 +49,7 @@ interface SSHManagerHostEditorProps {
|
||||
onFormSubmit?: () => void;
|
||||
}
|
||||
|
||||
export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHostEditorProps) {
|
||||
export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHostEditorProps) {
|
||||
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
||||
const [folders, setFolders] = 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),
|
||||
autoStart: z.boolean().default(false),
|
||||
})).default([]),
|
||||
enableConfigEditor: z.boolean().default(true),
|
||||
enableFileManager: z.boolean().default(true),
|
||||
defaultPath: z.string().optional(),
|
||||
}).superRefine((data, ctx) => {
|
||||
if (data.authType === 'password') {
|
||||
@@ -178,7 +178,7 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
|
||||
keyType: "auto",
|
||||
enableTerminal: editingHost?.enableTerminal !== false,
|
||||
enableTunnel: editingHost?.enableTunnel !== false,
|
||||
enableConfigEditor: editingHost?.enableConfigEditor !== false,
|
||||
enableFileManager: editingHost?.enableFileManager !== false,
|
||||
defaultPath: editingHost?.defaultPath || "/",
|
||||
tunnelConnections: editingHost?.tunnelConnections || [],
|
||||
}
|
||||
@@ -205,7 +205,7 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
|
||||
keyType: (editingHost.keyType as any) || "auto",
|
||||
enableTerminal: editingHost.enableTerminal !== false,
|
||||
enableTunnel: editingHost.enableTunnel !== false,
|
||||
enableConfigEditor: editingHost.enableConfigEditor !== false,
|
||||
enableFileManager: editingHost.enableFileManager !== false,
|
||||
defaultPath: editingHost.defaultPath || "/",
|
||||
tunnelConnections: editingHost.tunnelConnections || [],
|
||||
});
|
||||
@@ -227,7 +227,7 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
|
||||
keyType: "auto",
|
||||
enableTerminal: true,
|
||||
enableTunnel: true,
|
||||
enableConfigEditor: true,
|
||||
enableFileManager: true,
|
||||
defaultPath: "/",
|
||||
tunnelConnections: [],
|
||||
});
|
||||
@@ -251,6 +251,8 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
|
||||
if (onFormSubmit) {
|
||||
onFormSubmit();
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||
} catch (error) {
|
||||
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">
|
||||
<Form {...form}>
|
||||
<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">
|
||||
<TabsList>
|
||||
<TabsTrigger value="general">General</TabsTrigger>
|
||||
<TabsTrigger value="terminal">Terminal</TabsTrigger>
|
||||
<TabsTrigger value="tunnel">Tunnel</TabsTrigger>
|
||||
<TabsTrigger value="config_editor">Config Editor</TabsTrigger>
|
||||
<TabsTrigger value="file_manager">File Manager</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="general">
|
||||
<TabsContent value="general" className="pt-2">
|
||||
<FormLabel className="mb-3 font-bold">Connection Details</FormLabel>
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<FormField
|
||||
@@ -580,11 +582,11 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({field}) => (
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="password" {...field} />
|
||||
<Input type="password" placeholder="password" {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -809,7 +811,9 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
|
||||
render={({field: sourcePortField}) => (
|
||||
<FormItem className="col-span-4">
|
||||
<FormLabel>Source Port
|
||||
(Local)</FormLabel>
|
||||
(Source refers to the Current
|
||||
Connection Details in the
|
||||
General tab)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="22" {...sourcePortField} />
|
||||
@@ -984,13 +988,13 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="config_editor">
|
||||
<TabsContent value="file_manager">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enableConfigEditor"
|
||||
name="enableFileManager"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Enable Config Editor</FormLabel>
|
||||
<FormLabel>Enable File Manager</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
@@ -998,13 +1002,13 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enable/disable host visibility in Config Editor tab.
|
||||
Enable/disable host visibility in File Manager tab.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.watch('enableConfigEditor') && (
|
||||
{form.watch('enableFileManager') && (
|
||||
<div className="mt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -1016,7 +1020,7 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
|
||||
<Input placeholder="/home" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Set default directory shown when connected via
|
||||
Config Editor</FormDescription>
|
||||
File Manager</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -1025,9 +1029,18 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</ScrollArea>
|
||||
<footer className="shrink-0 w-full">
|
||||
<Separator className="p-0.25 mt-1 mb-3"/>
|
||||
<Button type="submit" variant="outline">{editingHost ? "Update Host" : "Add Host"}</Button>
|
||||
<footer className="shrink-0 w-full pb-0">
|
||||
<Separator className="p-0.25"/>
|
||||
<Button
|
||||
className=""
|
||||
type="submit"
|
||||
variant="outline"
|
||||
style={{
|
||||
transform: 'translateY(8px)'
|
||||
}}
|
||||
>
|
||||
{editingHost ? "Update Host" : "Add Host"}
|
||||
</Button>
|
||||
</footer>
|
||||
</form>
|
||||
</Form>
|
||||
@@ -5,8 +5,23 @@ 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";
|
||||
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;
|
||||
@@ -20,7 +35,7 @@ interface SSHHost {
|
||||
authType: string;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableConfigEditor: boolean;
|
||||
enableFileManager: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: any[];
|
||||
createdAt: string;
|
||||
@@ -31,11 +46,12 @@ interface SSHManagerHostViewerProps {
|
||||
onEditHost?: (host: SSHHost) => void;
|
||||
}
|
||||
|
||||
export function SSHManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
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();
|
||||
@@ -59,6 +75,7 @@ export function SSHManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
try {
|
||||
await deleteSSHHost(hostId);
|
||||
await fetchHosts();
|
||||
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||
} catch (err) {
|
||||
alert('Failed to delete host');
|
||||
}
|
||||
@@ -71,6 +88,48 @@ export function SSHManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
@@ -172,11 +231,122 @@ export function SSHManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
{filteredAndSortedHosts.length} hosts
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={fetchHosts} variant="outline" size="sm">
|
||||
Refresh
|
||||
</Button>
|
||||
<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={() => {
|
||||
window.open('https://docs.termix.site/json-import', '_blank');
|
||||
}}
|
||||
>
|
||||
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
|
||||
@@ -257,7 +427,7 @@ export function SSHManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
{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"
|
||||
<Badge key={index} variant="outline"
|
||||
className="text-xs px-1 py-0">
|
||||
<Tag className="h-2 w-2 mr-0.5"/>
|
||||
{tag}
|
||||
@@ -289,10 +459,10 @@ export function SSHManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
{host.enableConfigEditor && (
|
||||
{host.enableFileManager && (
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
<FileEdit className="h-2 w-2 mr-0.5"/>
|
||||
Config
|
||||
File Manager
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
287
src/ui/Apps/Server/Server.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
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, type 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();
|
||||
intervalId = window.setInterval(() => {
|
||||
if (isVisible) {
|
||||
fetchStatus();
|
||||
fetchMetrics();
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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 Status
|
||||
</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;
|
||||
}
|
||||
|
||||
export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
{hostConfig, isVisible, splitScreen = false},
|
||||
ref
|
||||
) {
|
||||
@@ -24,6 +24,42 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
||||
const wasDisconnectedBySSH = useRef(false);
|
||||
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
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, () => ({
|
||||
disconnect: () => {
|
||||
@@ -35,13 +71,27 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
||||
},
|
||||
fit: () => {
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
},
|
||||
sendInput: (data: string) => {
|
||||
if (webSocketRef.current?.readyState === 1) {
|
||||
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(() => {
|
||||
window.addEventListener('resize', handleWindowResize);
|
||||
@@ -49,7 +99,10 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
||||
}, []);
|
||||
|
||||
function handleWindowResize() {
|
||||
if (!isVisibleRef.current) return;
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
}
|
||||
|
||||
function getCookie(name: string) {
|
||||
@@ -63,6 +116,50 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
||||
return getCookie("rightClickCopyPaste") === "true"
|
||||
}
|
||||
|
||||
|
||||
|
||||
function setupWebSocketListeners(ws: WebSocket, cols: number, rows: number) {
|
||||
ws.addEventListener('open', () => {
|
||||
|
||||
ws.send(JSON.stringify({type: 'connectToHost', data: {cols, rows, hostConfig}}));
|
||||
terminal.onData((data) => {
|
||||
ws.send(JSON.stringify({type: 'input', data}));
|
||||
});
|
||||
|
||||
pingIntervalRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({type: 'ping'}));
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
|
||||
});
|
||||
|
||||
ws.addEventListener('message', (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'data') terminal.write(msg.data);
|
||||
else if (msg.type === 'error') terminal.writeln(`\r\n[ERROR] ${msg.message}`);
|
||||
else if (msg.type === 'connected') {
|
||||
} else if (msg.type === 'disconnected') {
|
||||
wasDisconnectedBySSH.current = true;
|
||||
terminal.writeln(`\r\n[${msg.message || 'Disconnected'}]`);
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
if (!wasDisconnectedBySSH.current) {
|
||||
terminal.writeln('\r\n[Connection closed]');
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener('error', () => {
|
||||
terminal.writeln('\r\n[Connection error]');
|
||||
});
|
||||
}
|
||||
|
||||
async function writeTextToClipboard(text: string): Promise<void> {
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
@@ -104,10 +201,7 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
||||
scrollback: 10000,
|
||||
fontSize: 14,
|
||||
fontFamily: '"JetBrains Mono Nerd Font", "MesloLGS NF", "FiraCode Nerd Font", "Cascadia Code", "JetBrains Mono", Consolas, "Courier New", monospace',
|
||||
theme: {
|
||||
background: '#09090b',
|
||||
foreground: '#f7f7f7',
|
||||
},
|
||||
theme: {background: '#18181b', foreground: '#f7f7f7'},
|
||||
allowTransparency: true,
|
||||
convertEol: true,
|
||||
windowsMode: false,
|
||||
@@ -145,90 +239,61 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
||||
}
|
||||
} else {
|
||||
const pasteText = await readTextFromClipboard();
|
||||
if (pasteText) {
|
||||
terminal.paste(pasteText);
|
||||
}
|
||||
if (pasteText) terminal.paste(pasteText);
|
||||
}
|
||||
} catch (_) {
|
||||
}
|
||||
};
|
||||
if (element) {
|
||||
element.addEventListener('contextmenu', handleContextMenu);
|
||||
}
|
||||
element?.addEventListener('contextmenu', handleContextMenu);
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||
resizeTimeout.current = setTimeout(() => {
|
||||
if (!isVisibleRef.current) return;
|
||||
fitAddonRef.current?.fit();
|
||||
const cols = terminal.cols;
|
||||
const rows = terminal.rows;
|
||||
if (webSocketRef.current?.readyState === WebSocket.OPEN) {
|
||||
webSocketRef.current.send(JSON.stringify({type: 'resize', data: {cols, rows}}));
|
||||
}
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
}, 100);
|
||||
});
|
||||
|
||||
resizeObserver.observe(xtermRef.current);
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
setVisible(true);
|
||||
|
||||
const cols = terminal.cols;
|
||||
const rows = terminal.rows;
|
||||
const wsUrl = window.location.hostname === 'localhost'
|
||||
? 'ws://localhost:8082'
|
||||
: `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
webSocketRef.current = ws;
|
||||
wasDisconnectedBySSH.current = false;
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
ws.send(JSON.stringify({type: 'connectToHost', data: {cols, rows, hostConfig}}));
|
||||
terminal.onData((data) => {
|
||||
ws.send(JSON.stringify({type: 'input', data}));
|
||||
});
|
||||
|
||||
pingIntervalRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({type: 'ping'}));
|
||||
const readyFonts = (document as any).fonts?.ready instanceof Promise ? (document as any).fonts.ready : Promise.resolve();
|
||||
readyFonts.then(() => {
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
setVisible(true);
|
||||
if (terminal && !splitScreen) {
|
||||
terminal.focus();
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
}, 0);
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
const cols = terminal.cols;
|
||||
const rows = terminal.rows;
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
if (!wasDisconnectedBySSH.current) {
|
||||
terminal.writeln('\r\n[Connection closed]');
|
||||
}
|
||||
});
|
||||
const isDev = process.env.NODE_ENV === 'development' &&
|
||||
(window.location.port === '3000' || window.location.port === '5173' || window.location.port === '');
|
||||
|
||||
ws.addEventListener('error', () => {
|
||||
terminal.writeln('\r\n[Connection error]');
|
||||
});
|
||||
}, 300);
|
||||
const wsUrl = isDev
|
||||
? 'ws://localhost:8082'
|
||||
: `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
webSocketRef.current = ws;
|
||||
wasDisconnectedBySSH.current = false;
|
||||
|
||||
setupWebSocketListeners(ws, cols, rows);
|
||||
}, 300);
|
||||
});
|
||||
|
||||
return () => {
|
||||
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 (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
@@ -240,22 +305,44 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible && fitAddonRef.current) {
|
||||
fitAddonRef.current.fit();
|
||||
setTimeout(() => {
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
if (terminal && !splitScreen) {
|
||||
terminal.focus();
|
||||
}
|
||||
}, 0);
|
||||
|
||||
if (terminal && !splitScreen) {
|
||||
setTimeout(() => {
|
||||
terminal.focus();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
}, [isVisible]);
|
||||
}, [isVisible, splitScreen, terminal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fitAddonRef.current) return;
|
||||
setTimeout(() => {
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
if (terminal && !splitScreen && isVisible) {
|
||||
terminal.focus();
|
||||
}
|
||||
}, 0);
|
||||
}, [splitScreen, isVisible, terminal]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={xtermRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
marginLeft: 2,
|
||||
opacity: visible && isVisible ? 1 : 0,
|
||||
overflow: 'hidden',
|
||||
<div
|
||||
ref={xtermRef}
|
||||
className="h-full w-full m-1"
|
||||
style={{opacity: visible && isVisible ? 1 : 0, overflow: 'hidden'}}
|
||||
onClick={() => {
|
||||
if (terminal && !splitScreen) {
|
||||
terminal.focus();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -1,11 +1,6 @@
|
||||
import React, {useState, useEffect, useCallback} from "react";
|
||||
import {SSHTunnelSidebar} from "@/apps/SSH/Tunnel/SSHTunnelSidebar.tsx";
|
||||
import {SSHTunnelViewer} from "@/apps/SSH/Tunnel/SSHTunnelViewer.tsx";
|
||||
import {getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel} from "@/apps/SSH/ssh-axios";
|
||||
|
||||
interface ConfigEditorProps {
|
||||
onSelectView: (view: string) => void;
|
||||
}
|
||||
import {TunnelViewer} from "@/ui/Apps/Tunnel/TunnelViewer.tsx";
|
||||
import {getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel} from "@/ui/main-axios.ts";
|
||||
|
||||
interface TunnelConnection {
|
||||
sourcePort: number;
|
||||
@@ -32,7 +27,7 @@ interface SSHHost {
|
||||
keyType?: string;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableConfigEditor: boolean;
|
||||
enableFileManager: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: TunnelConnection[];
|
||||
createdAt: string;
|
||||
@@ -49,31 +44,89 @@ interface TunnelStatus {
|
||||
retryExhausted?: boolean;
|
||||
}
|
||||
|
||||
export function SSHTunnel({onSelectView}: ConfigEditorProps): React.ReactElement {
|
||||
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
||||
interface SSHTunnelProps {
|
||||
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 [tunnelActions, setTunnelActions] = useState<Record<string, boolean>>({});
|
||||
|
||||
const fetchHosts = useCallback(async () => {
|
||||
try {
|
||||
const hostsData = await getSSHHosts();
|
||||
setHosts(hostsData);
|
||||
} catch (err) {
|
||||
const prevVisibleHostRef = React.useRef<SSHHost | null>(null);
|
||||
|
||||
const haveTunnelConnectionsChanged = (a: TunnelConnection[] = [], b: TunnelConnection[] = []): boolean => {
|
||||
if (a.length !== b.length) return true;
|
||||
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 () => {
|
||||
try {
|
||||
const statusData = await getTunnelStatuses();
|
||||
setTunnelStatuses(statusData);
|
||||
} catch (err) {
|
||||
}
|
||||
const statusData = await getTunnelStatuses();
|
||||
setTunnelStatuses(statusData);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchHosts();
|
||||
const interval = setInterval(fetchHosts, 10000);
|
||||
return () => clearInterval(interval);
|
||||
const interval = setInterval(fetchHosts, 5000);
|
||||
|
||||
const handleHostsChanged = () => {
|
||||
fetchHosts();
|
||||
};
|
||||
window.addEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
window.removeEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
|
||||
};
|
||||
}, [fetchHosts]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -90,7 +143,7 @@ export function SSHTunnel({onSelectView}: ConfigEditorProps): React.ReactElement
|
||||
|
||||
try {
|
||||
if (action === 'connect') {
|
||||
const endpointHost = hosts.find(h =>
|
||||
const endpointHost = allHosts.find(h =>
|
||||
h.name === tunnel.endpointHost ||
|
||||
`${h.username}@${h.ip}` === tunnel.endpointHost
|
||||
);
|
||||
@@ -141,20 +194,11 @@ export function SSHTunnel({onSelectView}: ConfigEditorProps): React.ReactElement
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full">
|
||||
<div className="w-64 flex-shrink-0">
|
||||
<SSHTunnelSidebar
|
||||
onSelectView={onSelectView}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<SSHTunnelViewer
|
||||
hosts={hosts}
|
||||
tunnelStatuses={tunnelStatuses}
|
||||
tunnelActions={tunnelActions}
|
||||
onTunnelAction={handleTunnelAction}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TunnelViewer
|
||||
hosts={visibleHosts}
|
||||
tunnelStatuses={tunnelStatuses}
|
||||
tunnelActions={tunnelActions}
|
||||
onTunnelAction={handleTunnelAction}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -53,7 +53,7 @@ interface SSHHost {
|
||||
authType: string;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableConfigEditor: boolean;
|
||||
enableFileManager: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: TunnelConnection[];
|
||||
createdAt: string;
|
||||
@@ -75,14 +75,18 @@ interface SSHTunnelObjectProps {
|
||||
tunnelStatuses: Record<string, TunnelStatus>;
|
||||
tunnelActions: Record<string, boolean>;
|
||||
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>;
|
||||
compact?: boolean;
|
||||
bare?: boolean;
|
||||
}
|
||||
|
||||
export function SSHTunnelObject({
|
||||
host,
|
||||
tunnelStatuses,
|
||||
tunnelActions,
|
||||
onTunnelAction
|
||||
}: SSHTunnelObjectProps): React.ReactElement {
|
||||
export function TunnelObject({
|
||||
host,
|
||||
tunnelStatuses,
|
||||
tunnelActions,
|
||||
onTunnelAction,
|
||||
compact = false,
|
||||
bare = false
|
||||
}: SSHTunnelObjectProps): React.ReactElement {
|
||||
|
||||
const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => {
|
||||
const tunnel = host.tunnelConnections[tunnelIndex];
|
||||
@@ -161,26 +165,166 @@ 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 (
|
||||
<Card className="w-full bg-card border-border shadow-sm hover:shadow-md transition-shadow relative group p-0">
|
||||
<div className="p-4">
|
||||
{/* Host Header */}
|
||||
<div className="flex items-center justify-between gap-2 mb-3">
|
||||
<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"/>}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-card-foreground truncate">
|
||||
{host.name || `${host.username}@${host.ip}`}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{host.ip}:{host.port} • {host.username}
|
||||
</p>
|
||||
{!compact && (
|
||||
<div className="flex items-center justify-between gap-2 mb-3">
|
||||
<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"/>}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-card-foreground truncate">
|
||||
{host.name || `${host.username}@${host.ip}`}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{host.ip}:{host.port} • {host.username}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{host.tags && host.tags.length > 0 && (
|
||||
{!compact && host.tags && host.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-3">
|
||||
{host.tags.slice(0, 3).map((tag, index) => (
|
||||
<Badge key={index} variant="secondary" className="text-xs px-1 py-0">
|
||||
@@ -196,14 +340,15 @@ export function SSHTunnelObject({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator className="mb-3"/>
|
||||
{!compact && <Separator className="mb-3"/>}
|
||||
|
||||
{/* Tunnel Connections */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-card-foreground flex items-center gap-2">
|
||||
<Network className="h-4 w-4"/>
|
||||
Tunnel Connections ({host.tunnelConnections.length})
|
||||
</h4>
|
||||
{!compact && (
|
||||
<h4 className="text-sm font-medium text-card-foreground flex items-center gap-2">
|
||||
<Network className="h-4 w-4"/>
|
||||
Tunnel Connections ({host.tunnelConnections.length})
|
||||
</h4>
|
||||
)}
|
||||
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{host.tunnelConnections.map((tunnel, tunnelIndex) => {
|
||||
@@ -221,7 +366,6 @@ export function SSHTunnelObject({
|
||||
return (
|
||||
<div key={tunnelIndex}
|
||||
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 gap-2 flex-1 min-w-0">
|
||||
<span className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}>
|
||||
@@ -237,13 +381,6 @@ export function SSHTunnelObject({
|
||||
</div>
|
||||
</div>
|
||||
<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 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{isConnected ? (
|
||||
@@ -296,7 +433,6 @@ export function SSHTunnelObject({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error/Status Reason */}
|
||||
{(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">
|
||||
@@ -321,7 +457,6 @@ export function SSHTunnelObject({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Retry Info */}
|
||||
{(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">
|
||||
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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
795
src/ui/Homepage/HomepageAuth.tsx
Normal file
@@ -0,0 +1,795 @@
|
||||
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,
|
||||
verifyTOTPLogin
|
||||
} 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);
|
||||
|
||||
const [totpRequired, setTotpRequired] = useState(false);
|
||||
const [totpCode, setTotpCode] = useState("");
|
||||
const [totpTempToken, setTotpTempToken] = useState("");
|
||||
const [totpLoading, setTotpLoading] = 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.requires_totp) {
|
||||
setTotpRequired(true);
|
||||
setTotpTempToken(res.temp_token);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
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("");
|
||||
}
|
||||
setTotpRequired(false);
|
||||
setTotpCode("");
|
||||
setTotpTempToken("");
|
||||
} 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 handleTOTPVerification() {
|
||||
if (totpCode.length !== 6) {
|
||||
setError("Please enter a 6-digit code");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setTotpLoading(true);
|
||||
|
||||
try {
|
||||
const res = await verifyTOTPLogin(totpTempToken, totpCode);
|
||||
|
||||
if (!res || !res.token) {
|
||||
throw new Error('No token received from TOTP verification');
|
||||
}
|
||||
|
||||
setCookie("jwt", res.token);
|
||||
const meRes = await 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);
|
||||
setTotpRequired(false);
|
||||
setTotpCode("");
|
||||
setTotpTempToken("");
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || err?.message || "Invalid TOTP code");
|
||||
} finally {
|
||||
setTotpLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
)}
|
||||
{totpRequired && (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="mb-6 text-center">
|
||||
<h2 className="text-xl font-bold mb-1">Two-Factor Authentication</h2>
|
||||
<p className="text-muted-foreground">Enter the 6-digit code from your authenticator app</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="totp-code">Authentication Code</Label>
|
||||
<Input
|
||||
id="totp-code"
|
||||
type="text"
|
||||
placeholder="000000"
|
||||
maxLength={6}
|
||||
value={totpCode}
|
||||
onChange={e => setTotpCode(e.target.value.replace(/\D/g, ''))}
|
||||
disabled={totpLoading}
|
||||
className="text-center text-2xl tracking-widest font-mono"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Or enter a backup code if you don't have access to your authenticator
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={totpLoading || totpCode.length < 6}
|
||||
onClick={handleTOTPVerification}
|
||||
>
|
||||
{totpLoading ? Spinner : "Verify"}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={totpLoading}
|
||||
onClick={() => {
|
||||
setTotpRequired(false);
|
||||
setTotpCode("");
|
||||
setTotpTempToken("");
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!internalLoggedIn && (!authLoading || !getCookie("jwt")) && !totpRequired) && (
|
||||
<>
|
||||
<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" && (
|
||||
<>o
|
||||
<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 {Button} from "@/components/ui/button.tsx";
|
||||
import {Separator} from "@/components/ui/separator.tsx";
|
||||
import axios from "axios";
|
||||
import { getReleasesRSS, getVersionInfo } from "@/ui/main-axios.ts";
|
||||
|
||||
interface HomepageUpdateLogProps extends React.ComponentProps<"div"> {
|
||||
loggedIn: boolean;
|
||||
@@ -50,12 +50,6 @@ interface VersionResponse {
|
||||
cache_age?: number;
|
||||
}
|
||||
|
||||
const apiBase = import.meta.env.DEV ? "http://localhost:8081" : "";
|
||||
|
||||
const API = axios.create({
|
||||
baseURL: apiBase,
|
||||
});
|
||||
|
||||
export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
|
||||
const [releases, setReleases] = useState<RSSResponse | null>(null);
|
||||
const [versionInfo, setVersionInfo] = useState<VersionResponse | null>(null);
|
||||
@@ -66,12 +60,12 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
|
||||
if (loggedIn) {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
API.get('/releases/rss?per_page=100'),
|
||||
API.get('/version/')
|
||||
getReleasesRSS(100),
|
||||
getVersionInfo()
|
||||
])
|
||||
.then(([releasesRes, versionRes]) => {
|
||||
setReleases(releasesRes.data);
|
||||
setVersionInfo(versionRes.data);
|
||||
setReleases(releasesRes);
|
||||
setVersionInfo(versionRes);
|
||||
setError(null);
|
||||
})
|
||||
.catch(err => {
|
||||
@@ -90,75 +84,67 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
|
||||
return firstLine
|
||||
.replace(/[#*`]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.substring(0, 100) + (firstLine.length > 100 ? '...' : '');
|
||||
.trim();
|
||||
};
|
||||
|
||||
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>
|
||||
<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' && (
|
||||
<Alert>
|
||||
<AlertTitle>Update Available</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Alert className="bg-[#0e0e10] border-[#303032] text-white">
|
||||
<AlertTitle className="text-white">Update Available</AlertTitle>
|
||||
<AlertDescription className="text-gray-300">
|
||||
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>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{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 && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
<Alert variant="destructive" className="bg-red-900/20 border-red-500 text-red-300">
|
||||
<AlertTitle className="text-red-300">Error</AlertTitle>
|
||||
<AlertDescription className="text-red-300">{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{releases?.items.map((release) => (
|
||||
<div
|
||||
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')}
|
||||
>
|
||||
<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}
|
||||
</h4>
|
||||
{release.isPrerelease && (
|
||||
<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
|
||||
</span>
|
||||
)}
|
||||
</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)}
|
||||
</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>
|
||||
{release.assets.length > 0 && (
|
||||
<>
|
||||
@@ -171,9 +157,9 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
|
||||
))}
|
||||
|
||||
{releases && releases.items.length === 0 && !loading && (
|
||||
<Alert>
|
||||
<AlertTitle>No Releases</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Alert className="bg-[#0e0e10] border-[#303032] text-gray-300">
|
||||
<AlertTitle className="text-gray-300">No Releases</AlertTitle>
|
||||
<AlertDescription className="text-gray-400">
|
||||
No releases found.
|
||||
</AlertDescription>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
112
src/ui/Navigation/Hosts/Host.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
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' | 'degraded'>('degraded');
|
||||
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 intervalId: number | undefined;
|
||||
let cancelled = false;
|
||||
|
||||
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, 10000);
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
692
src/ui/Navigation/LeftSidebar.tsx
Normal file
@@ -0,0 +1,692 @@
|
||||
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 border-2 !border-[#303032]" 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-[#222225] 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 !bg-[#222225] border-[#303032] rounded-md"
|
||||
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"
|
||||
>
|
||||
<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 (isSplitScreenActive) return;
|
||||
const profileTab = tabList.find((t: any) => t.type === 'profile');
|
||||
if (profileTab) {
|
||||
setCurrentTab(profileTab.id);
|
||||
return;
|
||||
}
|
||||
const id = addTab({type: 'profile', title: 'Profile'} as any);
|
||||
setCurrentTab(id);
|
||||
}}>
|
||||
<span>Profile & Security</span>
|
||||
</DropdownMenuItem>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
262
src/ui/User/PasswordReset.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card.tsx";
|
||||
import {Key} from "lucide-react";
|
||||
import React, {useState} from "react";
|
||||
import {completePasswordReset, initiatePasswordReset, verifyPasswordResetCode} from "@/ui/main-axios.ts";
|
||||
import {Label} from "@/components/ui/label.tsx";
|
||||
import {Input} from "@/components/ui/input.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
|
||||
|
||||
interface PasswordResetProps {
|
||||
userInfo: {
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
is_oidc: boolean;
|
||||
totp_enabled: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
export function PasswordReset({userInfo}: PasswordResetProps) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
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);
|
||||
|
||||
async function handleInitiatePasswordReset() {
|
||||
setError(null);
|
||||
setResetLoading(true);
|
||||
try {
|
||||
const result = await initiatePasswordReset(userInfo.username);
|
||||
setResetStep("verify");
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || err?.message || "Failed to initiate password reset");
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function resetPasswordState() {
|
||||
setResetStep("initiate");
|
||||
setResetCode("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setTempToken("");
|
||||
setError(null);
|
||||
setResetSuccess(false);
|
||||
}
|
||||
|
||||
async function handleVerifyResetCode() {
|
||||
setError(null);
|
||||
setResetLoading(true);
|
||||
try {
|
||||
const response = await verifyPasswordResetCode(userInfo.username, 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(userInfo.username, 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);
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Key className="w-5 h-5"/>
|
||||
Password
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Change your account password
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<>
|
||||
{resetStep === "initiate" && !resetSuccess && (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading || !userInfo.username.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>{userInfo.username}</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="">
|
||||
<AlertTitle>Success!</AlertTitle>
|
||||
<AlertDescription>
|
||||
Your password has been successfully reset! You can now log in
|
||||
with your new password.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</>
|
||||
)}
|
||||
|
||||
{resetStep === "newPassword" && !resetSuccess && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>Enter your new password for
|
||||
user: <strong>{userInfo.username}</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>
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mt-4">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
437
src/ui/User/TOTPSetup.tsx
Normal file
@@ -0,0 +1,437 @@
|
||||
import React, { useState } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { Label } from "@/components/ui/label.tsx";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs.tsx";
|
||||
import { Shield, Copy, Download, AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
import { setupTOTP, enableTOTP, disableTOTP, generateBackupCodes } from "@/ui/main-axios.ts";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface TOTPSetupProps {
|
||||
isEnabled: boolean;
|
||||
onStatusChange?: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSetupProps) {
|
||||
const [isEnabled, setIsEnabled] = useState(initialEnabled);
|
||||
const [isSettingUp, setIsSettingUp] = useState(false);
|
||||
const [setupStep, setSetupStep] = useState<"init" | "qr" | "verify" | "backup">("init");
|
||||
const [qrCode, setQrCode] = useState("");
|
||||
const [secret, setSecret] = useState("");
|
||||
const [verificationCode, setVerificationCode] = useState("");
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [password, setPassword] = useState("");
|
||||
const [disableCode, setDisableCode] = useState("");
|
||||
|
||||
const handleSetupStart = async () => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await setupTOTP();
|
||||
setQrCode(response.qr_code);
|
||||
setSecret(response.secret);
|
||||
setSetupStep("qr");
|
||||
setIsSettingUp(true);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || "Failed to start TOTP setup");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyCode = async () => {
|
||||
if (verificationCode.length !== 6) {
|
||||
setError("Please enter a 6-digit code");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await enableTOTP(verificationCode);
|
||||
setBackupCodes(response.backup_codes);
|
||||
setSetupStep("backup");
|
||||
toast.success("Two-factor authentication enabled successfully!");
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || "Invalid verification code");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisable = async () => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
await disableTOTP(password || undefined, disableCode || undefined);
|
||||
setIsEnabled(false);
|
||||
setIsSettingUp(false);
|
||||
setSetupStep("init");
|
||||
setPassword("");
|
||||
setDisableCode("");
|
||||
onStatusChange?.(false);
|
||||
toast.success("Two-factor authentication disabled");
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || "Failed to disable TOTP");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateNewBackupCodes = async () => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await generateBackupCodes(password || undefined, disableCode || undefined);
|
||||
setBackupCodes(response.backup_codes);
|
||||
toast.success("New backup codes generated");
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || "Failed to generate backup codes");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string, label: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success(`${label} copied to clipboard`);
|
||||
};
|
||||
|
||||
const downloadBackupCodes = () => {
|
||||
const content = `Termix Two-Factor Authentication Backup Codes\n` +
|
||||
`Generated: ${new Date().toISOString()}\n\n` +
|
||||
`Keep these codes in a safe place. Each code can only be used once.\n\n` +
|
||||
backupCodes.map((code, i) => `${i + 1}. ${code}`).join('\n');
|
||||
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'termix-backup-codes.txt';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success("Backup codes downloaded");
|
||||
};
|
||||
|
||||
const handleComplete = () => {
|
||||
setIsEnabled(true);
|
||||
setIsSettingUp(false);
|
||||
setSetupStep("init");
|
||||
setVerificationCode("");
|
||||
onStatusChange?.(true);
|
||||
};
|
||||
|
||||
if (isEnabled && !isSettingUp) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5" />
|
||||
Two-Factor Authentication
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Your account is protected with two-factor authentication
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
<AlertTitle>Enabled</AlertTitle>
|
||||
<AlertDescription>
|
||||
Two-factor authentication is currently active on your account
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Tabs defaultValue="disable" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="disable">Disable 2FA</TabsTrigger>
|
||||
<TabsTrigger value="backup">Backup Codes</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="disable" className="space-y-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Warning</AlertTitle>
|
||||
<AlertDescription>
|
||||
Disabling two-factor authentication will make your account less secure
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="disable-password">Password or TOTP Code</Label>
|
||||
<Input
|
||||
id="disable-password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">Or</p>
|
||||
<Input
|
||||
id="disable-code"
|
||||
type="text"
|
||||
placeholder="6-digit TOTP code"
|
||||
maxLength={6}
|
||||
value={disableCode}
|
||||
onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDisable}
|
||||
disabled={loading || (!password && !disableCode)}
|
||||
>
|
||||
Disable Two-Factor Authentication
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="backup" className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Generate new backup codes if you've lost your existing ones
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backup-password">Password or TOTP Code</Label>
|
||||
<Input
|
||||
id="backup-password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">Or</p>
|
||||
<Input
|
||||
id="backup-code"
|
||||
type="text"
|
||||
placeholder="6-digit TOTP code"
|
||||
maxLength={6}
|
||||
value={disableCode}
|
||||
onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleGenerateNewBackupCodes}
|
||||
disabled={loading || (!password && !disableCode)}
|
||||
>
|
||||
Generate New Backup Codes
|
||||
</Button>
|
||||
|
||||
{backupCodes.length > 0 && (
|
||||
<div className="space-y-2 mt-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>Your Backup Codes</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={downloadBackupCodes}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 p-4 bg-muted rounded-lg font-mono text-sm">
|
||||
{backupCodes.map((code, i) => (
|
||||
<div key={i}>{code}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (setupStep === "qr") {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Set Up Two-Factor Authentication</CardTitle>
|
||||
<CardDescription>
|
||||
Step 1: Scan the QR code with your authenticator app
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex justify-center">
|
||||
<img src={qrCode} alt="TOTP QR Code" className="w-64 h-64" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Manual Entry Code</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={secret}
|
||||
readOnly
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
size="default"
|
||||
variant="outline"
|
||||
onClick={() => copyToClipboard(secret, "Secret key")}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
If you can't scan the QR code, enter this code manually in your authenticator app
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button onClick={() => setSetupStep("verify")} className="w-full">
|
||||
Next: Verify Code
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (setupStep === "verify") {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Verify Your Authenticator</CardTitle>
|
||||
<CardDescription>
|
||||
Step 2: Enter the 6-digit code from your authenticator app
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="verify-code">Verification Code</Label>
|
||||
<Input
|
||||
id="verify-code"
|
||||
type="text"
|
||||
placeholder="000000"
|
||||
maxLength={6}
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value.replace(/\D/g, ''))}
|
||||
className="text-center text-2xl tracking-widest font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSetupStep("qr")}
|
||||
disabled={loading}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleVerifyCode}
|
||||
disabled={loading || verificationCode.length !== 6}
|
||||
className="flex-1"
|
||||
>
|
||||
{loading ? "Verifying..." : "Verify and Enable"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (setupStep === "backup") {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Save Your Backup Codes</CardTitle>
|
||||
<CardDescription>
|
||||
Step 3: Store these codes in a safe place
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Important</AlertTitle>
|
||||
<AlertDescription>
|
||||
Save these backup codes in a secure location. You can use them to access your account if you lose your authenticator device.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>Your Backup Codes</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={downloadBackupCodes}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 p-4 bg-muted rounded-lg font-mono text-sm">
|
||||
{backupCodes.map((code, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">{i + 1}.</span>
|
||||
<span>{code}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleComplete} className="w-full">
|
||||
Complete Setup
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5" />
|
||||
Two-Factor Authentication
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Add an extra layer of security to your account
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Not Enabled</AlertTitle>
|
||||
<AlertDescription>
|
||||
Two-factor authentication adds an extra layer of security by requiring a code from your authenticator app when signing in.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Button onClick={handleSetupStart} disabled={loading} className="w-full">
|
||||
{loading ? "Setting up..." : "Enable Two-Factor Authentication"}
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
166
src/ui/User/UserProfile.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import React, {useState, useEffect} from "react";
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Input} from "@/components/ui/input.tsx";
|
||||
import {Label} from "@/components/ui/label.tsx";
|
||||
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
|
||||
import {User, Shield, Key, AlertCircle} from "lucide-react";
|
||||
import {TOTPSetup} from "@/ui/User/TOTPSetup.tsx";
|
||||
import {getUserInfo} from "@/ui/main-axios.ts";
|
||||
import {toast} from "sonner";
|
||||
import {PasswordReset} from "@/ui/User/PasswordReset.tsx";
|
||||
|
||||
interface UserProfileProps {
|
||||
isTopbarOpen?: boolean;
|
||||
}
|
||||
|
||||
export function UserProfile({isTopbarOpen = true}: UserProfileProps) {
|
||||
const [userInfo, setUserInfo] = useState<{
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
is_oidc: boolean;
|
||||
totp_enabled: boolean;
|
||||
} | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserInfo();
|
||||
}, []);
|
||||
|
||||
const fetchUserInfo = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const info = await getUserInfo();
|
||||
setUserInfo({
|
||||
username: info.username,
|
||||
is_admin: info.is_admin,
|
||||
is_oidc: info.is_oidc,
|
||||
totp_enabled: info.totp_enabled || false
|
||||
});
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || "Failed to load user information");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTOTPStatusChange = (enabled: boolean) => {
|
||||
if (userInfo) {
|
||||
setUserInfo({...userInfo, totp_enabled: enabled});
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container max-w-4xl mx-auto p-6">
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center">
|
||||
<div className="animate-pulse">Loading user profile...</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !userInfo) {
|
||||
return (
|
||||
<div className="container max-w-4xl mx-auto p-6">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4"/>
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error || "Failed to load user profile"}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container max-w-4xl mx-auto p-6 overflow-y-auto" style={{
|
||||
marginTop: isTopbarOpen ? '60px' : '0',
|
||||
transition: 'margin-top 0.3s ease',
|
||||
maxHeight: 'calc(100vh - 60px)'
|
||||
}}>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold">User Profile</h1>
|
||||
<p className="text-muted-foreground mt-2">Manage your account settings and security</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="profile" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="profile" className="flex items-center gap-2">
|
||||
<User className="w-4 h-4"/>
|
||||
Profile
|
||||
</TabsTrigger>
|
||||
{!userInfo.is_oidc && (
|
||||
<TabsTrigger value="security" className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4"/>
|
||||
Security
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="profile" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Account Information</CardTitle>
|
||||
<CardDescription>Your account details and settings</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Username</Label>
|
||||
<p className="text-lg font-medium mt-1">{userInfo.username}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Account Type</Label>
|
||||
<p className="text-lg font-medium mt-1">
|
||||
{userInfo.is_admin ? "Administrator" : "User"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Authentication Method</Label>
|
||||
<p className="text-lg font-medium mt-1">
|
||||
{userInfo.is_oidc ? "External (OIDC)" : "Local"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Two-Factor Authentication</Label>
|
||||
<p className="text-lg font-medium mt-1">
|
||||
{userInfo.is_oidc ? (
|
||||
<span className="text-muted-foreground">Locked (OIDC Auth)</span>
|
||||
) : (
|
||||
userInfo.totp_enabled ? (
|
||||
<span className="text-green-600 flex items-center gap-1">
|
||||
<Shield className="w-4 h-4"/>
|
||||
Enabled
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Disabled</span>
|
||||
)
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="security" className="space-y-4">
|
||||
<TOTPSetup
|
||||
isEnabled={userInfo.totp_enabled}
|
||||
onStatusChange={handleTOTPStatusChange}
|
||||
/>
|
||||
|
||||
{!userInfo.is_oidc && (
|
||||
<PasswordReset
|
||||
userInfo={userInfo}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1003
src/ui/main-axios.ts
Normal file
@@ -6,16 +6,12 @@
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting - Made extremely permissive */
|
||||
"strict": false,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
@@ -31,8 +27,6 @@
|
||||
"allowUnreachableCode": true,
|
||||
"noImplicitOverride": false,
|
||||
"noEmitOnError": false,
|
||||
|
||||
/* shadcn */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
|
||||