Compare commits
129 Commits
release-1.
...
release-1.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bf61bda4d | ||
|
|
937e04fa5c | ||
|
|
a7fa40393d | ||
|
|
b91627d91b | ||
|
|
451c96868a | ||
|
|
d508ea2afc | ||
|
|
457b768c14 | ||
|
|
a2b9b1d59f | ||
|
|
6d20e3fcac | ||
|
|
cc0d6e0eb2 | ||
|
|
5c9e1c0d75 | ||
|
|
8ee63dbe9e | ||
|
|
cc3384f8e2 | ||
|
|
d92867223c | ||
|
|
2ac2787ec5 | ||
|
|
f11c0906e1 | ||
|
|
42e7ab8141 | ||
|
|
ced52ddd81 | ||
|
|
b3a986d826 | ||
|
|
7be6f4203e | ||
|
|
6a04a5d430 | ||
|
|
ee09a6808a | ||
|
|
0f75cd4d16 | ||
|
|
ab69661751 | ||
|
|
a31e855c36 | ||
|
|
d4dc310c93 | ||
|
|
046a3d6855 | ||
|
|
5cd9de9ac5 | ||
|
|
d85fb26a5d | ||
|
|
7756dc6a4c | ||
|
|
ff76137339 | ||
|
|
61db35daad | ||
|
|
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 |
129
.dockerignore
Normal file
@@ -0,0 +1,129 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Build outputs
|
||||
dist
|
||||
build
|
||||
.next
|
||||
.nuxt
|
||||
|
||||
# Development files
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# IDE and editor files
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
README-CN.md
|
||||
CONTRIBUTING.md
|
||||
LICENSE
|
||||
|
||||
# Docker files (avoid copying docker files into docker)
|
||||
# docker/ - commented out to allow entrypoint.sh to be copied
|
||||
|
||||
# Repository images
|
||||
repo-images/
|
||||
|
||||
# Uploads directory
|
||||
uploads/
|
||||
|
||||
# Electron files (not needed for Docker)
|
||||
electron/
|
||||
electron-builder.json
|
||||
|
||||
# Development and build artifacts
|
||||
*.log
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# Font files (we'll optimize these in Dockerfile)
|
||||
# public/fonts/*.ttf
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Dependency directories
|
||||
jspm_packages/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# next.js build output
|
||||
.next
|
||||
|
||||
# nuxt.js build output
|
||||
.nuxt
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# Serverless directories
|
||||
.serverless
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
2
.github/FUNDING.yml
vendored
@@ -1 +1 @@
|
||||
github: [LukeGus]
|
||||
github: [LukeGus]
|
||||
|
||||
82
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,82 @@
|
||||
name: Bug report
|
||||
description: Create a report to help Termix improve
|
||||
title: "[BUG]"
|
||||
labels: [bug]
|
||||
assignees: []
|
||||
body:
|
||||
- type: input
|
||||
id: title
|
||||
attributes:
|
||||
label: Title
|
||||
description: Brief, descriptive title for the bug
|
||||
placeholder: "Brief description of the bug"
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
label: Platform
|
||||
description: How are you using Termix?
|
||||
options:
|
||||
- Website - Firefox
|
||||
- Website - Safari
|
||||
- Website - Chrome
|
||||
- Website - Other Browser
|
||||
- App - Windows
|
||||
- App - Linux
|
||||
- App - iOS
|
||||
- App - Android
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: server-installation-method
|
||||
attributes:
|
||||
label: Server Installation Method
|
||||
description: How is the Termix server installed?
|
||||
options:
|
||||
- Docker
|
||||
- Manual Build
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: Find your version in the User Profile tab
|
||||
placeholder: "e.g., 1.7.0"
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: troubleshooting
|
||||
attributes:
|
||||
label: Troubleshooting
|
||||
description: Please check all that apply
|
||||
options:
|
||||
- label: I have examined logs and tried to find the issue
|
||||
- label: I have reviewed opened and closed issues
|
||||
- label: I have tried restarting the application
|
||||
- type: textarea
|
||||
id: problem-description
|
||||
attributes:
|
||||
label: The Problem
|
||||
description: Describe the bug in detail. Include as much information as possible with screenshots if applicable.
|
||||
placeholder: "Describe what went wrong..."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: reproduction-steps
|
||||
attributes:
|
||||
label: How to Reproduce
|
||||
description: Use as few steps as possible to reproduce the issue
|
||||
placeholder: |
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Any other context about the problem
|
||||
placeholder: "Add any other context about the problem here..."
|
||||
36
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: Feature request
|
||||
description: Suggest an idea for Termix
|
||||
title: "[FEATURE]"
|
||||
labels: [enhancement]
|
||||
assignees: []
|
||||
body:
|
||||
- type: input
|
||||
id: title
|
||||
attributes:
|
||||
label: Title
|
||||
description: Brief, descriptive title for the feature request
|
||||
placeholder: "Brief description of the feature"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: related-issue
|
||||
attributes:
|
||||
label: Is it related to an issue?
|
||||
description: Describe the problem this feature would solve
|
||||
placeholder: "Describe what problem this feature would solve..."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: The Solution
|
||||
description: Describe your proposed solution in detail
|
||||
placeholder: "Describe how you envision this feature working..."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Any other context or screenshots about the feature request
|
||||
placeholder: "Add any other context about the feature request here..."
|
||||
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"
|
||||
31
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
# Overview
|
||||
|
||||
_Short summary of what this PR does_
|
||||
|
||||
- [ ] Added: ...
|
||||
- [ ] Updated: ...
|
||||
- [ ] Removed: ...
|
||||
- [ ] Fixed: ...
|
||||
|
||||
# Changes Made
|
||||
|
||||
_Detailed explanation of changes (if needed)_
|
||||
|
||||
- ...
|
||||
|
||||
# Related Issues
|
||||
|
||||
_Link any issues this PR addresses_
|
||||
|
||||
- Closes #ISSUE_NUMBER
|
||||
- Related to #ISSUE_NUMBER
|
||||
|
||||
# Screenshots / Demos
|
||||
|
||||
_(Optional: add before/after screenshots, GIFs, or console output)_
|
||||
|
||||
# Checklist
|
||||
|
||||
- [ ] Code follows project style guidelines
|
||||
- [ ] Supports mobile and desktop UI/app (if applicable)
|
||||
- [ ] I have read [Contributing.md](https://github.com/LukeGus/Termix/blob/main/CONTRIBUTING.md)
|
||||
51
.github/workflows/docker-image.yml
vendored
@@ -1,25 +1,27 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- development
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- '.gitignore'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag_name:
|
||||
description: "Custom tag name for the Docker image"
|
||||
required: false
|
||||
default: ""
|
||||
registry:
|
||||
description: "Docker registry to push to"
|
||||
required: true
|
||||
default: "ghcr"
|
||||
type: choice
|
||||
options:
|
||||
- "ghcr"
|
||||
- "dockerhub"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -37,7 +39,7 @@ jobs:
|
||||
network=host
|
||||
|
||||
- name: Cache npm dependencies
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.npm
|
||||
@@ -48,7 +50,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') }}
|
||||
@@ -56,16 +58,26 @@ jobs:
|
||||
${{ runner.os }}-buildx-${{ github.ref_name }}-
|
||||
${{ runner.os }}-buildx-
|
||||
|
||||
- name: Login to Docker Registry
|
||||
- name: Login to GitHub Container Registry
|
||||
if: github.event.inputs.registry != 'dockerhub'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: github.event.inputs.registry == 'dockerhub'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: bugattiguy527
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Determine Docker image tag
|
||||
run: |
|
||||
echo "REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
||||
REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')
|
||||
echo "REPO_OWNER=$REPO_OWNER" >> $GITHUB_ENV
|
||||
|
||||
if [ "${{ github.event.inputs.tag_name }}" != "" ]; then
|
||||
IMAGE_TAG="${{ github.event.inputs.tag_name }}"
|
||||
elif [ "${{ github.ref }}" == "refs/heads/main" ]; then
|
||||
@@ -73,18 +85,27 @@ jobs:
|
||||
elif [ "${{ github.ref }}" == "refs/heads/development" ]; then
|
||||
IMAGE_TAG="development-latest"
|
||||
else
|
||||
IMAGE_TAG="${{ github.ref_name }}-development-latest"
|
||||
IMAGE_TAG="${{ github.ref_name }}"
|
||||
fi
|
||||
echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV
|
||||
|
||||
# Determine registry and image name
|
||||
if [ "${{ github.event.inputs.registry }}" == "dockerhub" ]; then
|
||||
echo "REGISTRY=docker.io" >> $GITHUB_ENV
|
||||
echo "IMAGE_NAME=bugattiguy527/termix" >> $GITHUB_ENV
|
||||
else
|
||||
echo "REGISTRY=ghcr.io" >> $GITHUB_ENV
|
||||
echo "IMAGE_NAME=$REPO_OWNER/termix" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- 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
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ghcr.io/${{ env.REPO_OWNER }}/termix:${{ env.IMAGE_TAG }}
|
||||
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
|
||||
labels: |
|
||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
@@ -101,7 +122,7 @@ jobs:
|
||||
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
|
||||
|
||||
- name: Delete all untagged image versions
|
||||
if: success()
|
||||
if: success() && github.event.inputs.registry != 'dockerhub'
|
||||
uses: quartx-analytics/ghcr-cleaner@v1
|
||||
with:
|
||||
owner-type: user
|
||||
|
||||
93
.github/workflows/electron-build.yml
vendored
Normal file
@@ -0,0 +1,93 @@
|
||||
name: Build Electron App
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build_type:
|
||||
description: "Build type to run"
|
||||
required: true
|
||||
default: "all"
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- windows
|
||||
- linux
|
||||
|
||||
jobs:
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
if: github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'windows' || github.event.inputs.build_type == ''
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build Windows Portable
|
||||
run: npm run build:win-portable
|
||||
|
||||
- name: Build Windows Installer
|
||||
run: npm run build:win-installer
|
||||
|
||||
- name: Create Windows Portable zip
|
||||
run: |
|
||||
Compress-Archive -Path "release/win-unpacked/*" -DestinationPath "Termix-Windows-Portable.zip"
|
||||
|
||||
- name: Upload Windows Portable Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Termix-Windows-Portable
|
||||
path: Termix-Windows-Portable.zip
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Windows Installer Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Termix-Windows-Installer
|
||||
path: release/*.exe
|
||||
retention-days: 30
|
||||
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'linux' || github.event.inputs.build_type == ''
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build Linux Portable
|
||||
run: npm run build:linux-portable
|
||||
|
||||
- name: Create Linux Portable zip
|
||||
run: |
|
||||
cd release/linux-unpacked
|
||||
zip -r ../../Termix-Linux-Portable.zip *
|
||||
cd ../..
|
||||
|
||||
- name: Upload Linux Portable Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Termix-Linux-Portable
|
||||
path: Termix-Linux-Portable.zip
|
||||
retention-days: 30
|
||||
4
.gitignore
vendored
@@ -23,3 +23,7 @@ dist-ssr
|
||||
*.sln
|
||||
*.sw?
|
||||
/db/
|
||||
/release/
|
||||
/.claude/
|
||||
/ssl/
|
||||
.env
|
||||
|
||||
3
.prettierignore
Normal file
@@ -0,0 +1,3 @@
|
||||
# Ignore artifacts:
|
||||
build
|
||||
coverage
|
||||
1
.prettierrc
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
128
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
- Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
- The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
mail@termix.site.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
106
CONTRIBUTING.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# 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
|
||||
npm run dev:backend
|
||||
```
|
||||
|
||||
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 "Feature request my new feature"
|
||||
```
|
||||
5. **Push to your fork**:
|
||||
```sh
|
||||
git push origin feature/my-feature-request
|
||||
```
|
||||
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.
|
||||
- Use the below color scheme with the respective CSS variable placed in the `className` of a div/component.
|
||||
- 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.
|
||||
- `MobileApp.tsx` renders when the users screen width is less than 768px, otherwise it loads the usual `DesktopApp.tsx`.
|
||||
|
||||
## Color Scheme
|
||||
|
||||
### Background Colors
|
||||
|
||||
| CSS Variable | Color Value | Usage | Description |
|
||||
| ----------------------------- | ----------- | --------------------------- | ---------------------------------------- |
|
||||
| `--color-dark-bg` | `#18181b` | Main dark background | Primary dark background color |
|
||||
| `--color-dark-bg-darker` | `#0e0e10` | Darker backgrounds | Darker variant for panels and containers |
|
||||
| `--color-dark-bg-darkest` | `#09090b` | Darkest backgrounds | Darkest background (terminal) |
|
||||
| `--color-dark-bg-light` | `#141416` | Light dark backgrounds | Lighter variant of dark background |
|
||||
| `--color-dark-bg-very-light` | `#101014` | Very light dark backgrounds | Very light variant of dark background |
|
||||
| `--color-dark-bg-panel` | `#1b1b1e` | Panel backgrounds | Background for panels and cards |
|
||||
| `--color-dark-bg-panel-hover` | `#232327` | Panel hover states | Background for panels on hover |
|
||||
|
||||
### Element-Specific Backgrounds
|
||||
|
||||
| CSS Variable | Color Value | Usage | Description |
|
||||
| ------------------------ | ----------- | ------------------ | --------------------------------------------- |
|
||||
| `--color-dark-bg-input` | `#222225` | Input fields | Background for input fields and form elements |
|
||||
| `--color-dark-bg-button` | `#23232a` | Button backgrounds | Background for buttons and clickable elements |
|
||||
| `--color-dark-bg-active` | `#1d1d1f` | Active states | Background for active/selected elements |
|
||||
| `--color-dark-bg-header` | `#131316` | Header backgrounds | Background for headers and navigation bars |
|
||||
|
||||
### Border Colors
|
||||
|
||||
| CSS Variable | Color Value | Usage | Description |
|
||||
| ---------------------------- | ----------- | --------------- | ---------------------------------------- |
|
||||
| `--color-dark-border` | `#303032` | Default borders | Standard border color |
|
||||
| `--color-dark-border-active` | `#2d2d30` | Active borders | Border color for active elements |
|
||||
| `--color-dark-border-hover` | `#434345` | Hover borders | Border color on hover states |
|
||||
| `--color-dark-border-light` | `#5a5a5d` | Light borders | Lighter border color for subtle elements |
|
||||
| `--color-dark-border-medium` | `#373739` | Medium borders | Medium weight border color |
|
||||
| `--color-dark-border-panel` | `#222224` | Panel borders | Border color for panels and cards |
|
||||
|
||||
### Interactive States
|
||||
|
||||
| CSS Variable | Color Value | Usage | Description |
|
||||
| ------------------------ | ----------- | ----------------- | --------------------------------------------- |
|
||||
| `--color-dark-hover` | `#2d2d30` | Hover states | Background color for hover effects |
|
||||
| `--color-dark-active` | `#2a2a2c` | Active states | Background color for active elements |
|
||||
| `--color-dark-pressed` | `#1a1a1c` | Pressed states | Background color for pressed/clicked elements |
|
||||
| `--color-dark-hover-alt` | `#2a2a2d` | Alternative hover | Alternative hover state color |
|
||||
|
||||
## Support
|
||||
|
||||
If you need help with Termix, you can join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support
|
||||
channel. You can also open an issue or open a pull request on the [GitHub](https://github.com/LukeGus/Termix/issues)
|
||||
repo.
|
||||
115
README-CN.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# 仓库统计
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md"><img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> 英文</a> |
|
||||
<img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文
|
||||
</p>
|
||||
|
||||

|
||||

|
||||

|
||||
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720"></a>
|
||||
|
||||
<p align="center">
|
||||
<img src="./repo-images/RepoOfTheDay.png" alt="Repo of the Day Achievement" style="width: 300px; height: auto;">
|
||||
<br>
|
||||
<small style="color: #666;">2025年9月1日获得</small>
|
||||
</p>
|
||||
|
||||
#### 核心技术
|
||||
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
|
||||
<br />
|
||||
<p align="center">
|
||||
<a href="https://github.com/LukeGus/Termix">
|
||||
<img alt="Termix Banner" src=./repo-images/HeaderImage.png style="width: auto; height: auto;"> </a>
|
||||
</p>
|
||||
|
||||
如果你愿意,可以在这里支持这个项目!\
|
||||
[](https://github.com/sponsors/LukeGus)
|
||||
|
||||
# 概览
|
||||
|
||||
<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 是一个开源、永久免费、自托管的一体化服务器管理平台。它提供了一个基于网页的解决方案,通过一个直观的界面管理你的服务器和基础设施。Termix
|
||||
提供 SSH 终端访问、SSH 隧道功能以及远程文件编辑,还会陆续添加更多工具。
|
||||
|
||||
# 功能
|
||||
|
||||
- **SSH 终端访问** - 功能完整的终端,支持分屏(最多 4 个面板)和标签系统
|
||||
- **SSH 隧道管理** - 创建和管理 SSH 隧道,支持自动重连和健康监控
|
||||
- **远程文件编辑器** - 直接在远程服务器编辑文件,支持语法高亮和文件管理功能(上传、删除、重命名等)
|
||||
- **SSH 主机管理器** - 保存、组织和管理 SSH 连接,支持标签和文件夹
|
||||
- **服务器统计** - 查看任意 SSH 服务器的 CPU、内存和硬盘使用情况
|
||||
- **用户认证** - 安全的用户管理,支持管理员控制、OIDC 和双因素认证(TOTP)
|
||||
- **现代化界面** - 使用 React、Tailwind CSS 和 Shadcn 构建的简洁界面
|
||||
- **语言支持** - 内置中英文支持
|
||||
|
||||
# 计划功能
|
||||
|
||||
- **增强管理员控制** - 提供更精细的用户和管理员权限控制、共享主机等功能
|
||||
- **主题定制** - 修改所有工具的主题风格
|
||||
- **增强终端支持** - 添加更多终端协议,如 VNC 和 RDP(有类似 Apache Guacamole 的 RDP 集成经验者请通过创建 issue 联系我)
|
||||
- **移动端支持** - 支持移动应用或 Termix 网站移动版,让你在手机上管理服务器
|
||||
|
||||
# 安装
|
||||
|
||||
访问 Termix [文档](https://docs.termix.site/install) 获取安装信息。或者可以参考以下示例 docker-compose 文件:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
termix:
|
||||
image: ghcr.io/lukegus/termix:latest
|
||||
container_name: termix
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- termix-data:/app/data
|
||||
environment:
|
||||
PORT: "8080"
|
||||
|
||||
volumes:
|
||||
termix-data:
|
||||
driver: local
|
||||
```
|
||||
|
||||
# 支持
|
||||
|
||||
如果你需要 Termix 的帮助,可以加入 [Discord](https://discord.gg/jVQGdvHDrf)
|
||||
服务器并访问支持频道。你也可以在 [GitHub](https://github.com/LukeGus/Termix/issues) 仓库提交 issue 或 pull request。
|
||||
|
||||
# 展示
|
||||
|
||||
<p align="center">
|
||||
<img src="./repo-images/Image 1.png" width="400" alt="Termix Demo 1"/>
|
||||
<img src="./repo-images/Image 2.png" width="400" alt="Termix Demo 2"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./repo-images/Image 3.png" width="250" alt="Termix Demo 3"/>
|
||||
<img src="./repo-images/Image 4.png" width="250" alt="Termix Demo 4"/>
|
||||
<img src="./repo-images/Image 5.png" width="250" alt="Termix Demo 5"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<video src="https://github.com/user-attachments/assets/f9caa061-10dc-4173-ae7d-c6d42f05cf56" width="800" controls>
|
||||
你的浏览器不支持 video 标签。
|
||||
</video>
|
||||
</p>
|
||||
|
||||
# 许可证
|
||||
|
||||
根据 Apache 2.0 许可证发布。更多信息请参见 LICENSE。
|
||||
83
README.md
@@ -1,9 +1,23 @@
|
||||
# Repo Stats
|
||||
|
||||
<p align="center">
|
||||
<img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> English |
|
||||
<a href="README-CN.md"><img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文</a>
|
||||
</p>
|
||||
|
||||

|
||||

|
||||

|
||||
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720"></a>
|
||||
|
||||
<p align="center">
|
||||
<img src="./repo-images/RepoOfTheDay.png" alt="Repo of the Day Achievement" style="width: 300px; height: auto;">
|
||||
<br>
|
||||
<small style="color: #666;">Achieved on September 1st, 2025</small>
|
||||
</p>
|
||||
|
||||
#### Top Technologies
|
||||
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
@@ -13,36 +27,59 @@
|
||||
[](#)
|
||||
[](#)
|
||||
|
||||
|
||||
<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, remote file management, 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
|
||||
- **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
|
||||
- **Remote File Manager** - Manage files directly on remote servers with support for viewing and editing code, images, audio, and video. Upload, download, rename, delete, and move files seamlessly.
|
||||
- **SSH Host Manager** - Save, organize, and manage your SSH connections with tags and folders and easily save reusable login info while being able to automate the deploying of SSH keys
|
||||
- **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
|
||||
- **Database Encryption** - SQLite database files encrypted at rest with automatic encryption/decryption
|
||||
- **Data Export/Import** - Export and import SSH hosts, credentials, and file manager data with incremental sync
|
||||
- **Automatic SSL Setup** - Built-in SSL certificate generation and management with HTTPS redirects
|
||||
- **Modern UI** - Clean desktop/mobile friendly interface built with React, Tailwind CSS, and Shadcn
|
||||
- **Languages** - Built-in support for English and Chinese
|
||||
- **Platform Support** - Available as a web app, desktop application (Windows & Linux), and dedicated mobile app for iOS and Android (coming in a few days)
|
||||
|
||||
# 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 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)
|
||||
|
||||
See [Projects](https://github.com/users/LukeGus/projects/3) for all planned features. If you are looking to contribute, see [Contributing](https://github.com/LukeGus/Termix/blob/main/CONTRIBUTING.md).
|
||||
|
||||
# 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:
|
||||
|
||||
Supported Devices:
|
||||
|
||||
- Website (any modern browser like Google, Safari, and Firefox)
|
||||
- Windows (app)
|
||||
- Linux (app)
|
||||
- iOS (coming in a few days)
|
||||
- Android (coming in a few days)
|
||||
- iPadOS and macOS are in progress
|
||||
|
||||
Visit the Termix [Docs](https://docs.termix.site/install) for more information on how to install Termix on all platforms. Otherwise, view
|
||||
a sample docker-compose file here:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
termix:
|
||||
@@ -58,11 +95,14 @@ services:
|
||||
|
||||
volumes:
|
||||
termix-data:
|
||||
driver: local
|
||||
driver: local
|
||||
```
|
||||
|
||||
# Support
|
||||
If you need help with Termix, you can join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support channel. You can also open an issue or open a pull request on the [GitHub](https://github.com/LukeGus/Termix/issues) repo.
|
||||
|
||||
If you need help with Termix, you can join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support
|
||||
channel. You can also open an issue or open a pull request on the [GitHub](https://github.com/LukeGus/Termix/issues)
|
||||
repo.
|
||||
|
||||
# Show-off
|
||||
|
||||
@@ -72,16 +112,21 @@ If you need help with Termix, you can join the [Discord](https://discord.gg/jVQG
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./repo-images/Image 3.png" width="250" alt="Termix Demo 3"/>
|
||||
<img src="./repo-images/Image 4.png" width="250" alt="Termix Demo 4"/>
|
||||
<img src="./repo-images/Image 5.png" width="250" alt="Termix Demo 5"/>
|
||||
<img src="./repo-images/Image 3.png" width="400" alt="Termix Demo 3"/>
|
||||
<img src="./repo-images/Image 4.png" width="400" alt="Termix Demo 4"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<video src="https://github.com/user-attachments/assets/0f95495d-c5db-48f5-b18b-9ab48bb10d31" width="800" controls>
|
||||
<img src="./repo-images/Image 5.png" width="400" alt="Termix Demo 5"/>
|
||||
<img src="./repo-images/Image 6.png" width="400" alt="Termix Demo 6"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<video src="https://github.com/user-attachments/assets/88936e0d-2399-4122-8eee-c255c25da48c" width="800" controls>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</p>
|
||||
|
||||
# License
|
||||
|
||||
Distributed under the Apache License Version 2.0. See LICENSE for more information.
|
||||
|
||||
5
SECURITY.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report any vulnerabilities to [GitHub Security](https://github.com/LukeGus/Termix/security/advisories).
|
||||
@@ -18,4 +18,4 @@
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
# Stage 1: Install dependencies and build frontend
|
||||
FROM node:18-alpine AS deps
|
||||
# Stage 1: Install dependencies
|
||||
FROM node:22-slim AS deps
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache python3 make g++
|
||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm ci --force && \
|
||||
ENV npm_config_target_platform=linux
|
||||
ENV npm_config_target_arch=x64
|
||||
ENV npm_config_target_libc=glibc
|
||||
|
||||
RUN rm -rf node_modules package-lock.json && \
|
||||
npm install --force && \
|
||||
npm cache clean --force
|
||||
|
||||
# Stage 2: Build frontend
|
||||
@@ -15,64 +20,69 @@ WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm run build
|
||||
RUN find public/fonts -name "*.ttf" ! -name "*Regular.ttf" ! -name "*Bold.ttf" ! -name "*Italic.ttf" -delete
|
||||
|
||||
# Stage 3: Build backend TypeScript
|
||||
RUN npm cache clean --force && \
|
||||
npm run build
|
||||
|
||||
# Stage 3: Build backend
|
||||
FROM deps AS backend-builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
ENV npm_config_target_platform=linux
|
||||
ENV npm_config_target_arch=x64
|
||||
ENV npm_config_target_libc=glibc
|
||||
|
||||
RUN npm rebuild better-sqlite3 --force
|
||||
|
||||
RUN npm run build:backend
|
||||
|
||||
# Stage 4: Production dependencies
|
||||
FROM node:18-alpine AS production-deps
|
||||
# Stage 4: Production dependencies only
|
||||
FROM node:22-slim AS production-deps
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
ENV npm_config_target_platform=linux
|
||||
ENV npm_config_target_arch=x64
|
||||
ENV npm_config_target_libc=glibc
|
||||
|
||||
RUN npm ci --only=production --ignore-scripts --force && \
|
||||
npm rebuild better-sqlite3 bcryptjs --force && \
|
||||
npm cache clean --force
|
||||
|
||||
# Stage 5: Build native modules
|
||||
FROM node:18-alpine AS native-builder
|
||||
# Stage 5: Final optimized image
|
||||
FROM node:22-slim
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm ci --only=production bcryptjs better-sqlite3 --force && \
|
||||
npm cache clean --force
|
||||
|
||||
# Stage 6: Final image
|
||||
FROM node:18-alpine
|
||||
ENV DATA_DIR=/app/data \
|
||||
PORT=8080 \
|
||||
NODE_ENV=production
|
||||
|
||||
RUN apk add --no-cache nginx gettext su-exec && \
|
||||
mkdir -p /app/data && \
|
||||
chown -R node:node /app/data
|
||||
RUN apt-get update && apt-get install -y nginx gettext-base openssl && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
mkdir -p /app/data /app/uploads && \
|
||||
chown -R node:node /app/data /app/uploads && \
|
||||
useradd -r -s /bin/false nginx
|
||||
|
||||
COPY docker/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY --from=frontend-builder /app/dist /usr/share/nginx/html
|
||||
RUN chown -R nginx:nginx /usr/share/nginx/html
|
||||
COPY docker/nginx-https.conf /etc/nginx/nginx-https.conf
|
||||
|
||||
WORKDIR /app
|
||||
COPY --chown=nginx:nginx --from=frontend-builder /app/dist /usr/share/nginx/html
|
||||
COPY --chown=nginx:nginx --from=frontend-builder /app/src/locales /usr/share/nginx/html/locales
|
||||
COPY --chown=nginx:nginx --from=frontend-builder /app/public/fonts /usr/share/nginx/html/fonts
|
||||
|
||||
COPY --from=production-deps /app/node_modules /app/node_modules
|
||||
COPY --from=native-builder /app/node_modules/bcryptjs /app/node_modules/bcryptjs
|
||||
COPY --from=native-builder /app/node_modules/better-sqlite3 /app/node_modules/better-sqlite3
|
||||
COPY --from=backend-builder /app/dist/backend ./dist/backend
|
||||
|
||||
COPY package.json ./
|
||||
COPY .env ./.env
|
||||
RUN chown -R node:node /app
|
||||
COPY --chown=node:node --from=production-deps /app/node_modules /app/node_modules
|
||||
COPY --chown=node:node --from=backend-builder /app/dist/backend ./dist/backend
|
||||
COPY --chown=node:node package.json ./
|
||||
|
||||
VOLUME ["/app/data"]
|
||||
|
||||
EXPOSE ${PORT} 8081 8082 8083 8084
|
||||
EXPOSE ${PORT} 30001 30002 30003 30004 30005
|
||||
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
services:
|
||||
termix:
|
||||
image: ghcr.io/lukegus/termix:latest
|
||||
container_name: termix
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- termix-data:/app/data
|
||||
environment:
|
||||
PORT: "8080"
|
||||
|
||||
volumes:
|
||||
termix-data:
|
||||
driver: local
|
||||
@@ -2,14 +2,95 @@
|
||||
set -e
|
||||
|
||||
export PORT=${PORT:-8080}
|
||||
export ENABLE_SSL=${ENABLE_SSL:-false}
|
||||
export SSL_PORT=${SSL_PORT:-8443}
|
||||
export SSL_CERT_PATH=${SSL_CERT_PATH:-/app/data/ssl/termix.crt}
|
||||
export SSL_KEY_PATH=${SSL_KEY_PATH:-/app/data/ssl/termix.key}
|
||||
|
||||
echo "Configuring web UI to run on port: $PORT"
|
||||
|
||||
envsubst '${PORT}' < /etc/nginx/nginx.conf > /etc/nginx/nginx.conf.tmp
|
||||
if [ "$ENABLE_SSL" = "true" ]; then
|
||||
echo "SSL enabled - using HTTPS configuration with redirect"
|
||||
NGINX_CONF_SOURCE="/etc/nginx/nginx-https.conf"
|
||||
else
|
||||
echo "SSL disabled - using HTTP-only configuration (default)"
|
||||
NGINX_CONF_SOURCE="/etc/nginx/nginx.conf"
|
||||
fi
|
||||
|
||||
envsubst '${PORT} ${SSL_PORT} ${SSL_CERT_PATH} ${SSL_KEY_PATH}' < $NGINX_CONF_SOURCE > /etc/nginx/nginx.conf.tmp
|
||||
mv /etc/nginx/nginx.conf.tmp /etc/nginx/nginx.conf
|
||||
|
||||
mkdir -p /app/data
|
||||
chown -R node:node /app/data
|
||||
chmod 755 /app/data
|
||||
mkdir -p /app/data /app/uploads
|
||||
chown -R node:node /app/data /app/uploads
|
||||
chmod 755 /app/data /app/uploads
|
||||
|
||||
if [ "$ENABLE_SSL" = "true" ]; then
|
||||
echo "Checking SSL certificate configuration..."
|
||||
mkdir -p /app/data/ssl
|
||||
chown -R node:node /app/data/ssl
|
||||
chmod 755 /app/data/ssl
|
||||
|
||||
DOMAIN=${SSL_DOMAIN:-localhost}
|
||||
|
||||
if [ -f "/app/data/ssl/termix.crt" ] && [ -f "/app/data/ssl/termix.key" ]; then
|
||||
echo "SSL certificates found, checking validity..."
|
||||
|
||||
if openssl x509 -in /app/data/ssl/termix.crt -checkend 2592000 -noout >/dev/null 2>&1; then
|
||||
echo "SSL certificates are valid and will be reused for domain: $DOMAIN"
|
||||
else
|
||||
echo "SSL certificate is expired or expiring soon, regenerating..."
|
||||
rm -f /app/data/ssl/termix.crt /app/data/ssl/termix.key
|
||||
fi
|
||||
else
|
||||
echo "SSL certificates not found, will generate new ones..."
|
||||
fi
|
||||
|
||||
if [ ! -f "/app/data/ssl/termix.crt" ] || [ ! -f "/app/data/ssl/termix.key" ]; then
|
||||
echo "Generating SSL certificates for domain: $DOMAIN"
|
||||
|
||||
cat > /app/data/ssl/openssl.conf << EOF
|
||||
[req]
|
||||
default_bits = 2048
|
||||
prompt = no
|
||||
default_md = sha256
|
||||
distinguished_name = dn
|
||||
req_extensions = v3_req
|
||||
|
||||
[dn]
|
||||
C=US
|
||||
ST=State
|
||||
L=City
|
||||
O=Termix
|
||||
OU=IT Department
|
||||
CN=$DOMAIN
|
||||
|
||||
[v3_req]
|
||||
basicConstraints = CA:FALSE
|
||||
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
|
||||
subjectAltName = @alt_names
|
||||
|
||||
[alt_names]
|
||||
DNS.1 = $DOMAIN
|
||||
DNS.2 = localhost
|
||||
DNS.3 = 127.0.0.1
|
||||
IP.1 = 127.0.0.1
|
||||
IP.2 = ::1
|
||||
IP.3 = 0.0.0.0
|
||||
EOF
|
||||
|
||||
openssl genrsa -out /app/data/ssl/termix.key 2048
|
||||
|
||||
openssl req -new -x509 -key /app/data/ssl/termix.key -out /app/data/ssl/termix.crt -days 365 -config /app/data/ssl/openssl.conf -extensions v3_req
|
||||
|
||||
chmod 600 /app/data/ssl/termix.key
|
||||
chmod 644 /app/data/ssl/termix.crt
|
||||
chown node:node /app/data/ssl/termix.key /app/data/ssl/termix.crt
|
||||
|
||||
rm -f /app/data/ssl/openssl.conf
|
||||
|
||||
echo "SSL certificates generated successfully for domain: $DOMAIN"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Starting nginx..."
|
||||
nginx
|
||||
@@ -18,10 +99,21 @@ echo "Starting backend services..."
|
||||
cd /app
|
||||
export NODE_ENV=production
|
||||
|
||||
if command -v su-exec > /dev/null 2>&1; then
|
||||
su-exec node node dist/backend/starter.js
|
||||
if [ -f "package.json" ]; then
|
||||
VERSION=$(grep '"version"' package.json | sed 's/.*"version": *"\([^"]*\)".*/\1/')
|
||||
if [ -n "$VERSION" ]; then
|
||||
export VERSION
|
||||
else
|
||||
echo "Warning: Could not extract version from package.json"
|
||||
fi
|
||||
else
|
||||
su -s /bin/sh node -c "node dist/backend/starter.js"
|
||||
echo "Warning: package.json not found"
|
||||
fi
|
||||
|
||||
if command -v su-exec > /dev/null 2>&1; then
|
||||
su-exec node node dist/backend/backend/starter.js
|
||||
else
|
||||
su -s /bin/sh node -c "node dist/backend/backend/starter.js"
|
||||
fi
|
||||
|
||||
echo "All services started"
|
||||
|
||||
267
docker/nginx-https.conf
Normal file
@@ -0,0 +1,267 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
client_header_timeout 300s;
|
||||
|
||||
set_real_ip_from 127.0.0.1;
|
||||
real_ip_header X-Forwarded-For;
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
server {
|
||||
listen ${PORT};
|
||||
server_name _;
|
||||
|
||||
return 301 https://$host:${SSL_PORT}$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen ${SSL_PORT} ssl;
|
||||
server_name _;
|
||||
|
||||
ssl_certificate ${SSL_CERT_PATH};
|
||||
ssl_certificate_key ${SSL_KEY_PATH};
|
||||
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
}
|
||||
|
||||
location ~* \.map$ {
|
||||
return 404;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
|
||||
location ~ ^/users(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
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 ~ ^/version(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
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 ~ ^/releases(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
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 ~ ^/alerts(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
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 ~ ^/credentials(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
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;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
location ~ ^/database(/.*)?$ {
|
||||
client_max_body_size 5G;
|
||||
client_body_timeout 300s;
|
||||
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
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;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location ~ ^/db(/.*)?$ {
|
||||
client_max_body_size 5G;
|
||||
client_body_timeout 300s;
|
||||
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
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;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location ~ ^/encryption(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
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:30001;
|
||||
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/websocket/ {
|
||||
proxy_pass http://127.0.0.1:30002/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
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;
|
||||
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
proxy_connect_timeout 10s;
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
|
||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
||||
}
|
||||
|
||||
location /ssh/tunnel/ {
|
||||
proxy_pass http://127.0.0.1:30003;
|
||||
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/recent {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
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:30001;
|
||||
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:30001;
|
||||
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/ {
|
||||
client_max_body_size 5G;
|
||||
client_body_timeout 300s;
|
||||
|
||||
proxy_pass http://127.0.0.1:30004;
|
||||
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;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location /health {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
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 ~ ^/status(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30005;
|
||||
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:30005;
|
||||
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;
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,18 +8,38 @@ http {
|
||||
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
client_header_timeout 300s;
|
||||
|
||||
set_real_ip_from 127.0.0.1;
|
||||
real_ip_header X-Forwarded-For;
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
server {
|
||||
listen ${PORT};
|
||||
server_name localhost;
|
||||
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
}
|
||||
|
||||
location /users/ {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
location ~* \.map$ {
|
||||
return 404;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
|
||||
location ~ ^/users(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -27,8 +47,8 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /version/ {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
location ~ ^/version(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -36,8 +56,8 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /releases/ {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
location ~ ^/releases(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -45,8 +65,77 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ssh/db/ {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
location ~ ^/alerts(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
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 ~ ^/credentials(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
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;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
location ~ ^/database(/.*)?$ {
|
||||
client_max_body_size 5G;
|
||||
client_body_timeout 300s;
|
||||
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
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;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location ~ ^/db(/.*)?$ {
|
||||
client_max_body_size 5G;
|
||||
client_body_timeout 300s;
|
||||
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
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;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location ~ ^/encryption(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
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:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -55,20 +144,30 @@ http {
|
||||
}
|
||||
|
||||
location /ssh/websocket/ {
|
||||
proxy_pass http://127.0.0.1:8082/;
|
||||
proxy_pass http://127.0.0.1:30002/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
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;
|
||||
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
proxy_connect_timeout 10s;
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
|
||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
||||
}
|
||||
|
||||
location /ssh/tunnel/ {
|
||||
proxy_pass http://127.0.0.1:8083;
|
||||
proxy_pass http://127.0.0.1:30003;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -76,8 +175,8 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ssh/config_editor/ {
|
||||
proxy_pass http://127.0.0.1:8084;
|
||||
location /ssh/file_manager/recent {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -85,8 +184,63 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/ssh/config_editor/(recent|pinned|shortcuts) {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
location /ssh/file_manager/pinned {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
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:30001;
|
||||
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/ {
|
||||
client_max_body_size 5G;
|
||||
client_body_timeout 300s;
|
||||
|
||||
proxy_pass http://127.0.0.1:30004;
|
||||
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;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location /health {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
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 ~ ^/status(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30005;
|
||||
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:30005;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
62
electron-builder.json
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"appId": "com.termix.app",
|
||||
"productName": "Termix",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"electron/**/*",
|
||||
"public/**/*",
|
||||
"!**/node_modules/**/*",
|
||||
"!src/**/*",
|
||||
"!*.md",
|
||||
"!tsconfig*.json",
|
||||
"!vite.config.ts",
|
||||
"!eslint.config.js"
|
||||
],
|
||||
"asarUnpack": ["node_modules/node-fetch/**/*"],
|
||||
"extraMetadata": {
|
||||
"main": "electron/main.cjs"
|
||||
},
|
||||
"buildDependenciesFromSource": false,
|
||||
"nodeGypRebuild": false,
|
||||
"npmRebuild": false,
|
||||
"win": {
|
||||
"target": "nsis",
|
||||
"icon": "public/icon.ico",
|
||||
"executableName": "Termix"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"artifactName": "${productName}-Setup-${version}.${ext}",
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true,
|
||||
"shortcutName": "Termix",
|
||||
"uninstallDisplayName": "Termix"
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
{
|
||||
"target": "AppImage",
|
||||
"arch": ["x64"]
|
||||
},
|
||||
{
|
||||
"target": "tar.gz",
|
||||
"arch": ["x64"]
|
||||
}
|
||||
],
|
||||
"icon": "public/icon.png",
|
||||
"category": "Development",
|
||||
"executableName": "termix",
|
||||
"desktop": {
|
||||
"entry": {
|
||||
"Name": "Termix",
|
||||
"Comment": "A web-based server management platform",
|
||||
"Keywords": "terminal;ssh;server;management;",
|
||||
"StartupWMClass": "termix"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
493
electron/main.cjs
Normal file
@@ -0,0 +1,493 @@
|
||||
const { app, BrowserWindow, shell, ipcMain, dialog } = require("electron");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
|
||||
app.commandLine.appendSwitch("--ignore-certificate-errors");
|
||||
app.commandLine.appendSwitch("--ignore-ssl-errors");
|
||||
app.commandLine.appendSwitch("--ignore-certificate-errors-spki-list");
|
||||
app.commandLine.appendSwitch("--enable-features=NetworkService");
|
||||
|
||||
if (process.platform === "linux") {
|
||||
app.commandLine.appendSwitch("--no-sandbox");
|
||||
app.commandLine.appendSwitch("--disable-setuid-sandbox");
|
||||
app.commandLine.appendSwitch("--disable-dev-shm-usage");
|
||||
}
|
||||
|
||||
let mainWindow = null;
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development" || !app.isPackaged;
|
||||
|
||||
const gotTheLock = app.requestSingleInstanceLock();
|
||||
if (!gotTheLock) {
|
||||
console.log("Another instance is already running, quitting...");
|
||||
app.quit();
|
||||
process.exit(0);
|
||||
} else {
|
||||
app.on("second-instance", (event, commandLine, workingDirectory) => {
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.focus();
|
||||
mainWindow.show();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
title: "Termix",
|
||||
icon: isDev
|
||||
? path.join(__dirname, "..", "public", "icon.png")
|
||||
: path.join(process.resourcesPath, "public", "icon.png"),
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
webSecurity: true,
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
},
|
||||
show: false,
|
||||
});
|
||||
|
||||
if (process.platform !== "darwin") {
|
||||
mainWindow.setMenuBarVisibility(false);
|
||||
}
|
||||
|
||||
if (isDev) {
|
||||
mainWindow.loadURL("http://localhost:5173");
|
||||
mainWindow.webContents.openDevTools();
|
||||
} else {
|
||||
const indexPath = path.join(__dirname, "..", "dist", "index.html");
|
||||
mainWindow.loadFile(indexPath);
|
||||
}
|
||||
|
||||
mainWindow.once("ready-to-show", () => {
|
||||
mainWindow.show();
|
||||
});
|
||||
|
||||
mainWindow.webContents.on(
|
||||
"did-fail-load",
|
||||
(event, errorCode, errorDescription, validatedURL) => {
|
||||
console.error(
|
||||
"Failed to load:",
|
||||
errorCode,
|
||||
errorDescription,
|
||||
validatedURL,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
mainWindow.webContents.on("did-finish-load", () => {
|
||||
console.log("Frontend loaded successfully");
|
||||
});
|
||||
|
||||
mainWindow.on("close", (event) => {
|
||||
if (process.platform === "darwin") {
|
||||
event.preventDefault();
|
||||
mainWindow.hide();
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.on("closed", () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url);
|
||||
return { action: "deny" };
|
||||
});
|
||||
}
|
||||
|
||||
ipcMain.handle("get-app-version", () => {
|
||||
return app.getVersion();
|
||||
});
|
||||
|
||||
const GITHUB_API_BASE = "https://api.github.com";
|
||||
const REPO_OWNER = "LukeGus";
|
||||
const REPO_NAME = "Termix";
|
||||
|
||||
const githubCache = new Map();
|
||||
const CACHE_DURATION = 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
async function fetchGitHubAPI(endpoint, cacheKey) {
|
||||
const cached = githubCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
|
||||
return {
|
||||
data: cached.data,
|
||||
cached: true,
|
||||
cache_age: Date.now() - cached.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
let fetch;
|
||||
try {
|
||||
fetch = globalThis.fetch || require("node-fetch");
|
||||
} catch (e) {
|
||||
const https = require("https");
|
||||
const http = require("http");
|
||||
const { URL } = require("url");
|
||||
|
||||
fetch = (url, options = {}) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const urlObj = new URL(url);
|
||||
const isHttps = urlObj.protocol === "https:";
|
||||
const client = isHttps ? https : http;
|
||||
|
||||
const requestOptions = {
|
||||
method: options.method || "GET",
|
||||
headers: options.headers || {},
|
||||
timeout: options.timeout || 10000,
|
||||
};
|
||||
|
||||
if (isHttps) {
|
||||
requestOptions.rejectUnauthorized = false;
|
||||
requestOptions.agent = new https.Agent({
|
||||
rejectUnauthorized: false,
|
||||
secureProtocol: "TLSv1_2_method",
|
||||
checkServerIdentity: () => undefined,
|
||||
ciphers: "ALL:!ADH:!LOW:!EXP:!MD5:@STRENGTH",
|
||||
honorCipherOrder: true,
|
||||
});
|
||||
}
|
||||
|
||||
const req = client.request(url, requestOptions, (res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk) => (data += chunk));
|
||||
res.on("end", () => {
|
||||
resolve({
|
||||
ok: res.statusCode >= 200 && res.statusCode < 300,
|
||||
status: res.statusCode,
|
||||
text: () => Promise.resolve(data),
|
||||
json: () => Promise.resolve(JSON.parse(data)),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
req.on("error", reject);
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
reject(new Error("Request timeout"));
|
||||
});
|
||||
|
||||
if (options.body) {
|
||||
req.write(options.body);
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
"User-Agent": "TermixElectronUpdateChecker/1.0",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`GitHub API error: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
githubCache.set(cacheKey, {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
return {
|
||||
data: data,
|
||||
cached: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch from GitHub API:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle("check-electron-update", async () => {
|
||||
try {
|
||||
const localVersion = app.getVersion();
|
||||
|
||||
const releaseData = await fetchGitHubAPI(
|
||||
`/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
|
||||
"latest_release_electron",
|
||||
);
|
||||
|
||||
const rawTag = releaseData.data.tag_name || releaseData.data.name || "";
|
||||
const remoteVersionMatch = rawTag.match(/(\d+\.\d+(\.\d+)?)/);
|
||||
const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null;
|
||||
|
||||
if (!remoteVersion) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Remote version not found",
|
||||
localVersion,
|
||||
};
|
||||
}
|
||||
|
||||
const isUpToDate = localVersion === remoteVersion;
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
status: isUpToDate ? "up_to_date" : "requires_update",
|
||||
localVersion: localVersion,
|
||||
remoteVersion: remoteVersion,
|
||||
latest_release: {
|
||||
tag_name: releaseData.data.tag_name,
|
||||
name: releaseData.data.name,
|
||||
published_at: releaseData.data.published_at,
|
||||
html_url: releaseData.data.html_url,
|
||||
body: releaseData.data.body,
|
||||
},
|
||||
cached: releaseData.cached,
|
||||
cache_age: releaseData.cache_age,
|
||||
};
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
localVersion: app.getVersion(),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("get-platform", () => {
|
||||
return process.platform;
|
||||
});
|
||||
|
||||
ipcMain.handle("get-server-config", () => {
|
||||
try {
|
||||
const userDataPath = app.getPath("userData");
|
||||
const configPath = path.join(userDataPath, "server-config.json");
|
||||
|
||||
if (fs.existsSync(configPath)) {
|
||||
const configData = fs.readFileSync(configPath, "utf8");
|
||||
return JSON.parse(configData);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("Error reading server config:", error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("save-server-config", (event, config) => {
|
||||
try {
|
||||
const userDataPath = app.getPath("userData");
|
||||
const configPath = path.join(userDataPath, "server-config.json");
|
||||
|
||||
if (!fs.existsSync(userDataPath)) {
|
||||
fs.mkdirSync(userDataPath, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Error saving server config:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("test-server-connection", async (event, serverUrl) => {
|
||||
try {
|
||||
const https = require("https");
|
||||
const http = require("http");
|
||||
const { URL } = require("url");
|
||||
|
||||
const fetch = (url, options = {}) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const urlObj = new URL(url);
|
||||
const isHttps = urlObj.protocol === "https:";
|
||||
const client = isHttps ? https : http;
|
||||
|
||||
const requestOptions = {
|
||||
method: options.method || "GET",
|
||||
headers: options.headers || {},
|
||||
timeout: options.timeout || 10000,
|
||||
};
|
||||
|
||||
if (isHttps) {
|
||||
requestOptions.rejectUnauthorized = false;
|
||||
requestOptions.agent = new https.Agent({
|
||||
rejectUnauthorized: false,
|
||||
secureProtocol: "TLSv1_2_method",
|
||||
checkServerIdentity: () => undefined,
|
||||
ciphers: "ALL:!ADH:!LOW:!EXP:!MD5:@STRENGTH",
|
||||
honorCipherOrder: true,
|
||||
});
|
||||
}
|
||||
|
||||
const req = client.request(url, requestOptions, (res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk) => (data += chunk));
|
||||
res.on("end", () => {
|
||||
resolve({
|
||||
ok: res.statusCode >= 200 && res.statusCode < 300,
|
||||
status: res.statusCode,
|
||||
text: () => Promise.resolve(data),
|
||||
json: () => Promise.resolve(JSON.parse(data)),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
req.on("error", reject);
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
reject(new Error("Request timeout"));
|
||||
});
|
||||
|
||||
if (options.body) {
|
||||
req.write(options.body);
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
};
|
||||
|
||||
const normalizedServerUrl = serverUrl.replace(/\/$/, "");
|
||||
|
||||
const healthUrl = `${normalizedServerUrl}/health`;
|
||||
|
||||
try {
|
||||
const response = await fetch(healthUrl, {
|
||||
method: "GET",
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.text();
|
||||
|
||||
if (
|
||||
data.includes("<html") ||
|
||||
data.includes("<!DOCTYPE") ||
|
||||
data.includes("<head>") ||
|
||||
data.includes("<body>")
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"Server returned HTML instead of JSON. This does not appear to be a Termix server.",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const healthData = JSON.parse(data);
|
||||
if (
|
||||
healthData &&
|
||||
(healthData.status === "ok" ||
|
||||
healthData.status === "healthy" ||
|
||||
healthData.healthy === true ||
|
||||
healthData.database === "connected")
|
||||
) {
|
||||
return {
|
||||
success: true,
|
||||
status: response.status,
|
||||
testedUrl: healthUrl,
|
||||
};
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.log("Health endpoint did not return valid JSON");
|
||||
}
|
||||
}
|
||||
} catch (urlError) {
|
||||
console.error("Health check failed:", urlError);
|
||||
}
|
||||
|
||||
try {
|
||||
const versionUrl = `${normalizedServerUrl}/version`;
|
||||
const response = await fetch(versionUrl, {
|
||||
method: "GET",
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.text();
|
||||
|
||||
if (
|
||||
data.includes("<html") ||
|
||||
data.includes("<!DOCTYPE") ||
|
||||
data.includes("<head>") ||
|
||||
data.includes("<body>")
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"Server returned HTML instead of JSON. This does not appear to be a Termix server.",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const versionData = JSON.parse(data);
|
||||
if (
|
||||
versionData &&
|
||||
(versionData.status === "up_to_date" ||
|
||||
versionData.status === "requires_update" ||
|
||||
(versionData.localVersion &&
|
||||
versionData.version &&
|
||||
versionData.latest_release))
|
||||
) {
|
||||
return {
|
||||
success: true,
|
||||
status: response.status,
|
||||
testedUrl: versionUrl,
|
||||
warning:
|
||||
"Health endpoint not available, but server appears to be running",
|
||||
};
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.log("Version endpoint did not return valid JSON");
|
||||
}
|
||||
}
|
||||
} catch (versionError) {
|
||||
console.error("Version check failed:", versionError);
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"Server is not responding or does not appear to be a valid Termix server. Please ensure the server is running and accessible.",
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
app.whenReady().then(() => {
|
||||
createWindow();
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
} else if (mainWindow) {
|
||||
mainWindow.show();
|
||||
}
|
||||
});
|
||||
|
||||
app.on("will-quit", () => {
|
||||
console.log("App will quit...");
|
||||
});
|
||||
|
||||
process.on("uncaughtException", (error) => {
|
||||
console.error("Uncaught Exception:", error);
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", (reason, promise) => {
|
||||
console.error("Unhandled Rejection at:", promise, "reason:", reason);
|
||||
});
|
||||
28
electron/preload.js
Normal file
@@ -0,0 +1,28 @@
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
contextBridge.exposeInMainWorld("electronAPI", {
|
||||
getAppVersion: () => ipcRenderer.invoke("get-app-version"),
|
||||
getPlatform: () => ipcRenderer.invoke("get-platform"),
|
||||
checkElectronUpdate: () => ipcRenderer.invoke("check-electron-update"),
|
||||
|
||||
getServerConfig: () => ipcRenderer.invoke("get-server-config"),
|
||||
saveServerConfig: (config) =>
|
||||
ipcRenderer.invoke("save-server-config", config),
|
||||
testServerConnection: (serverUrl) =>
|
||||
ipcRenderer.invoke("test-server-connection", serverUrl),
|
||||
|
||||
showSaveDialog: (options) => ipcRenderer.invoke("show-save-dialog", options),
|
||||
showOpenDialog: (options) => ipcRenderer.invoke("show-open-dialog", options),
|
||||
|
||||
onUpdateAvailable: (callback) => ipcRenderer.on("update-available", callback),
|
||||
onUpdateDownloaded: (callback) =>
|
||||
ipcRenderer.on("update-downloaded", callback),
|
||||
|
||||
removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel),
|
||||
isElectron: true,
|
||||
isDev: process.env.NODE_ENV === "development",
|
||||
|
||||
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
|
||||
});
|
||||
|
||||
window.IS_ELECTRON = true;
|
||||
@@ -1,18 +1,18 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { globalIgnores } from 'eslint/config'
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
import { globalIgnores } from "eslint/config";
|
||||
|
||||
export default tseslint.config([
|
||||
globalIgnores(['dist']),
|
||||
globalIgnores(["dist"]),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs['recommended-latest'],
|
||||
reactHooks.configs["recommended-latest"],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
@@ -20,4 +20,4 @@ export default tseslint.config([
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
]);
|
||||
|
||||
2305
openapi.json
Normal file
10637
package-lock.json
generated
83
package.json
@@ -1,51 +1,67 @@
|
||||
{
|
||||
"name": "termix",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "1.7.2",
|
||||
"description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities",
|
||||
"author": "Karmaa",
|
||||
"main": "electron/main.cjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "npx prettier . --write",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build": "vite build && tsc -p tsconfig.node.json",
|
||||
"build:backend": "tsc -p tsconfig.node.json",
|
||||
"dev:backend": "tsc -p tsconfig.node.json && node ./dist/backend/starter.js",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"dev:backend": "tsc -p tsconfig.node.json && node ./dist/backend/backend/starter.js",
|
||||
"preview": "vite preview",
|
||||
"electron:dev": "concurrently \"npm run dev\" \"wait-on http://localhost:5173 && electron .\"",
|
||||
"build:win-portable": "npm run build && electron-builder --win --dir",
|
||||
"build:win-installer": "npm run build && electron-builder --win --publish=never",
|
||||
"build:linux-portable": "npm run build && electron-builder --linux --dir",
|
||||
"build:linux-appimage": "npm run build && electron-builder --linux AppImage",
|
||||
"build:linux-targz": "npm run build && electron-builder --linux tar.gz",
|
||||
"test:encryption": "tsc -p tsconfig.node.json && node ./dist/backend/backend/utils/encryption-test.js",
|
||||
"migrate:encryption": "tsc -p tsconfig.node.json && node ./dist/backend/backend/utils/encryption-migration.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.18.7",
|
||||
"@codemirror/commands": "^6.3.3",
|
||||
"@codemirror/search": "^6.5.11",
|
||||
"@codemirror/theme-one-dark": "^6.1.2",
|
||||
"@codemirror/view": "^6.23.1",
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@radix-ui/react-accordion": "^1.2.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",
|
||||
"@radix-ui/react-slider": "^1.3.5",
|
||||
"@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/cookie-parser": "^1.4.9",
|
||||
"@types/jszip": "^3.4.0",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@uiw/codemirror-extensions-hyper-link": "^4.24.1",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/speakeasy": "^2.0.10",
|
||||
"@uiw/codemirror-extensions-langs": "^4.24.1",
|
||||
"@uiw/codemirror-themes": "^4.24.1",
|
||||
"@uiw/react-codemirror": "^4.24.1",
|
||||
"@xterm/addon-attach": "^0.11.0",
|
||||
"@xterm/addon-clipboard": "^0.1.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.15.0",
|
||||
"@xterm/addon-unicode11": "^0.8.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"axios": "^1.10.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"body-parser": "^1.20.2",
|
||||
"chalk": "^4.1.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -54,45 +70,62 @@
|
||||
"dotenv": "^17.2.0",
|
||||
"drizzle-orm": "^0.44.3",
|
||||
"express": "^5.1.0",
|
||||
"i18next": "^25.4.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"jose": "^5.2.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"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-h5-audio-player": "^3.10.1",
|
||||
"react-hook-form": "^7.60.0",
|
||||
"react-i18next": "^15.7.3",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-pdf": "^10.1.0",
|
||||
"react-photo-view": "^1.2.7",
|
||||
"react-player": "^3.3.3",
|
||||
"react-resizable-panels": "^3.0.3",
|
||||
"react-simple-keyboard": "^3.8.120",
|
||||
"react-syntax-highlighter": "^15.6.6",
|
||||
"react-xtermjs": "^1.0.10",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"speakeasy": "^2.0.0",
|
||||
"ssh2": "^1.16.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"validator": "^13.15.15",
|
||||
"wait-on": "^9.0.1",
|
||||
"ws": "^8.18.3",
|
||||
"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",
|
||||
"concurrently": "^9.2.1",
|
||||
"electron": "^38.0.0",
|
||||
"electron-builder": "^26.0.12",
|
||||
"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"
|
||||
"prettier": "3.6.2",
|
||||
"typescript": "~5.9.2",
|
||||
"typescript-eslint": "^8.40.0",
|
||||
"vite": "^7.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 168 KiB |
BIN
public/icon.icns
Normal file
BIN
public/icon.ico
Normal file
|
After Width: | Height: | Size: 353 KiB |
BIN
public/icon.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 6.1 KiB |
BIN
public/icons/1024x1024.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
public/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
public/icons/16x16.png
Normal file
|
After Width: | Height: | Size: 483 B |
BIN
public/icons/24x24.png
Normal file
|
After Width: | Height: | Size: 749 B |
BIN
public/icons/256x256.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
public/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 986 B |
BIN
public/icons/48x48.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
public/icons/512x512.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
public/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
public/icons/icon.icns
Normal file
BIN
public/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 353 KiB |
58128
public/pdf.worker.min.js
vendored
Normal file
BIN
repo-images/HeaderImage.png
Normal file
|
After Width: | Height: | Size: 524 KiB |
|
Before Width: | Height: | Size: 614 KiB After Width: | Height: | Size: 776 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 309 KiB |
|
Before Width: | Height: | Size: 397 KiB After Width: | Height: | Size: 418 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 780 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 305 KiB |
BIN
repo-images/Image 6.png
Normal file
|
After Width: | Height: | Size: 360 KiB |
BIN
repo-images/RepoOfTheDay.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
103
scripts/enable-ssl.sh
Normal file
@@ -0,0 +1,103 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
ENV_FILE="$PROJECT_ROOT/.env"
|
||||
|
||||
log_info() {
|
||||
echo -e "${BLUE}[SSL Setup]${NC} $1"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}[SSL Setup]${NC} $1"
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
echo -e "${YELLOW}[SSL Setup]${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[SSL Setup]${NC} $1"
|
||||
}
|
||||
|
||||
log_header() {
|
||||
echo -e "${CYAN}$1${NC}"
|
||||
}
|
||||
|
||||
generate_keys() {
|
||||
log_info "Generating security keys..."
|
||||
|
||||
JWT_SECRET=$(openssl rand -hex 32)
|
||||
log_success "Generated JWT secret"
|
||||
|
||||
DATABASE_KEY=$(openssl rand -hex 32)
|
||||
log_success "Generated database encryption key"
|
||||
|
||||
echo "JWT_SECRET=$JWT_SECRET" >> "$ENV_FILE"
|
||||
echo "DATABASE_KEY=$DATABASE_KEY" >> "$ENV_FILE"
|
||||
|
||||
log_success "Security keys added to .env file"
|
||||
}
|
||||
|
||||
setup_env_file() {
|
||||
log_info "Setting up environment configuration..."
|
||||
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
log_warn ".env file already exists, creating backup..."
|
||||
cp "$ENV_FILE" "$ENV_FILE.backup.$(date +%s)"
|
||||
fi
|
||||
|
||||
cat > "$ENV_FILE" << EOF
|
||||
# Termix SSL Configuration - Auto-generated $(date)
|
||||
|
||||
# SSL/TLS Configuration
|
||||
ENABLE_SSL=true
|
||||
SSL_PORT=8443
|
||||
SSL_DOMAIN=localhost
|
||||
PORT=8080
|
||||
|
||||
# Node environment
|
||||
NODE_ENV=production
|
||||
|
||||
# CORS configuration
|
||||
ALLOWED_ORIGINS=*
|
||||
|
||||
EOF
|
||||
|
||||
generate_keys
|
||||
|
||||
log_success "Environment configuration created at $ENV_FILE"
|
||||
}
|
||||
|
||||
setup_ssl_certificates() {
|
||||
log_info "Setting up SSL certificates..."
|
||||
|
||||
if [[ -f "$SCRIPT_DIR/setup-ssl.sh" ]]; then
|
||||
bash "$SCRIPT_DIR/setup-ssl.sh"
|
||||
else
|
||||
log_error "SSL setup script not found at $SCRIPT_DIR/setup-ssl.sh"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
if ! command -v openssl &> /dev/null; then
|
||||
log_error "OpenSSL is not installed. Please install OpenSSL first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
setup_env_file
|
||||
setup_ssl_certificates
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
121
scripts/setup-ssl.sh
Normal file
@@ -0,0 +1,121 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
SSL_DIR="$(dirname "$0")/../ssl"
|
||||
CERT_FILE="$SSL_DIR/termix.crt"
|
||||
KEY_FILE="$SSL_DIR/termix.key"
|
||||
DAYS_VALID=365
|
||||
|
||||
DOMAIN=${SSL_DOMAIN:-"localhost"}
|
||||
ALT_NAMES=${SSL_ALT_NAMES:-"DNS:localhost,DNS:127.0.0.1,DNS:*.localhost,IP:127.0.0.1"}
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
log_info() {
|
||||
echo -e "${BLUE}[SSL Setup]${NC} $1"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}[SSL Setup]${NC} $1"
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
echo -e "${YELLOW}[SSL Setup]${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[SSL Setup]${NC} $1"
|
||||
}
|
||||
|
||||
check_existing_cert() {
|
||||
if [[ -f "$CERT_FILE" && -f "$KEY_FILE" ]]; then
|
||||
if openssl x509 -in "$CERT_FILE" -checkend 2592000 -noout 2>/dev/null; then
|
||||
log_success "Valid SSL certificate already exists"
|
||||
|
||||
local expiry=$(openssl x509 -in "$CERT_FILE" -noout -enddate 2>/dev/null | cut -d= -f2)
|
||||
log_info "Expires: $expiry"
|
||||
return 0
|
||||
else
|
||||
log_warn "Existing certificate is expired or expiring soon"
|
||||
fi
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
generate_certificate() {
|
||||
log_info "Generating new SSL certificate for domain: $DOMAIN"
|
||||
|
||||
mkdir -p "$SSL_DIR"
|
||||
|
||||
local config_file="$SSL_DIR/openssl.conf"
|
||||
cat > "$config_file" << EOF
|
||||
[req]
|
||||
default_bits = 2048
|
||||
prompt = no
|
||||
default_md = sha256
|
||||
distinguished_name = dn
|
||||
req_extensions = v3_req
|
||||
|
||||
[dn]
|
||||
C=US
|
||||
ST=State
|
||||
L=City
|
||||
O=Termix
|
||||
OU=IT Department
|
||||
CN=$DOMAIN
|
||||
|
||||
[v3_req]
|
||||
basicConstraints = CA:FALSE
|
||||
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
|
||||
subjectAltName = @alt_names
|
||||
|
||||
[alt_names]
|
||||
DNS.1 = localhost
|
||||
DNS.2 = 127.0.0.1
|
||||
DNS.3 = *.localhost
|
||||
IP.1 = 127.0.0.1
|
||||
EOF
|
||||
|
||||
if [[ -n "$SSL_ALT_NAMES" ]]; then
|
||||
local counter=2
|
||||
IFS=',' read -ra NAMES <<< "$SSL_ALT_NAMES"
|
||||
for name in "${NAMES[@]}"; do
|
||||
name=$(echo "$name" | xargs)
|
||||
if [[ "$name" == DNS:* ]]; then
|
||||
echo "DNS.$((counter++)) = ${name#DNS:}" >> "$config_file"
|
||||
elif [[ "$name" == IP:* ]]; then
|
||||
echo "IP.$((counter++)) = ${name#IP:}" >> "$config_file"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
log_info "Generating private key..."
|
||||
openssl genrsa -out "$KEY_FILE" 2048
|
||||
|
||||
log_info "Generating certificate..."
|
||||
openssl req -new -x509 -key "$KEY_FILE" -out "$CERT_FILE" -days $DAYS_VALID -config "$config_file" -extensions v3_req
|
||||
|
||||
chmod 600 "$KEY_FILE"
|
||||
chmod 644 "$CERT_FILE"
|
||||
|
||||
rm -f "$config_file"
|
||||
|
||||
log_success "SSL certificate generated successfully"
|
||||
log_info "Valid for: $DAYS_VALID days"
|
||||
}
|
||||
|
||||
main() {
|
||||
if ! command -v openssl &> /dev/null; then
|
||||
log_error "OpenSSL is not installed. Please install OpenSSL first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
generate_certificate
|
||||
}
|
||||
|
||||
main "$@"
|
||||
56
src/App.tsx
@@ -1,56 +0,0 @@
|
||||
import React from "react"
|
||||
|
||||
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 App() {
|
||||
const [view, setView] = React.useState<string>("homepage")
|
||||
const [mountedViews, setMountedViews] = React.useState<Set<string>>(new Set(["homepage"]))
|
||||
|
||||
const handleSelectView = (nextView: string) => {
|
||||
setMountedViews((prev) => {
|
||||
if (prev.has(nextView)) return prev
|
||||
const next = new Set(prev)
|
||||
next.add(nextView)
|
||||
return next
|
||||
})
|
||||
setView(nextView)
|
||||
}
|
||||
|
||||
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>
|
||||
)}
|
||||
{mountedViews.has("ssh_manager") && (
|
||||
<div style={{display: view === "ssh_manager" ? "block" : "none"}}>
|
||||
<SSHManager onSelectView={handleSelectView} />
|
||||
</div>
|
||||
)}
|
||||
{mountedViews.has("terminal") && (
|
||||
<div style={{display: view === "terminal" ? "block" : "none"}}>
|
||||
<Terminal onSelectView={handleSelectView} />
|
||||
</div>
|
||||
)}
|
||||
{mountedViews.has("tunnel") && (
|
||||
<div style={{display: view === "tunnel" ? "block" : "none"}}>
|
||||
<SSHTunnel onSelectView={handleSelectView} />
|
||||
</div>
|
||||
)}
|
||||
{mountedViews.has("config_editor") && (
|
||||
<div style={{display: view === "config_editor" ? "block" : "none"}}>
|
||||
<ConfigEditor onSelectView={handleSelectView} />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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,184 +0,0 @@
|
||||
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";
|
||||
|
||||
interface HomepageUpdateLogProps extends React.ComponentProps<"div"> {
|
||||
loggedIn: boolean;
|
||||
}
|
||||
|
||||
interface ReleaseItem {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
link: string;
|
||||
pubDate: string;
|
||||
version: string;
|
||||
isPrerelease: boolean;
|
||||
isDraft: boolean;
|
||||
assets: Array<{
|
||||
name: string;
|
||||
size: number;
|
||||
download_count: number;
|
||||
download_url: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface RSSResponse {
|
||||
feed: {
|
||||
title: string;
|
||||
description: string;
|
||||
link: string;
|
||||
updated: string;
|
||||
};
|
||||
items: ReleaseItem[];
|
||||
total_count: number;
|
||||
cached: boolean;
|
||||
cache_age?: number;
|
||||
}
|
||||
|
||||
interface VersionResponse {
|
||||
status: 'up_to_date' | 'requires_update';
|
||||
version: string;
|
||||
latest_release: {
|
||||
name: string;
|
||||
published_at: string;
|
||||
html_url: string;
|
||||
};
|
||||
cached: boolean;
|
||||
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);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (loggedIn) {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
API.get('/releases/rss?per_page=100'),
|
||||
API.get('/version/')
|
||||
])
|
||||
.then(([releasesRes, versionRes]) => {
|
||||
setReleases(releasesRes.data);
|
||||
setVersionInfo(versionRes.data);
|
||||
setError(null);
|
||||
})
|
||||
.catch(err => {
|
||||
setError('Failed to fetch update information');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [loggedIn]);
|
||||
|
||||
if (!loggedIn) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const formatDescription = (description: string) => {
|
||||
const firstLine = description.split('\n')[0];
|
||||
return firstLine
|
||||
.replace(/[#*`]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.substring(0, 100) + (firstLine.length > 100 ? '...' : '');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-[400px] h-[600px] flex flex-col border border-border rounded-lg bg-card p-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Updates & Releases</h3>
|
||||
|
||||
<Separator className="p-0.25 mt-3 mb-3"/>
|
||||
|
||||
{versionInfo && versionInfo.status === 'requires_update' && (
|
||||
<Alert>
|
||||
<AlertTitle>Update Available</AlertTitle>
|
||||
<AlertDescription>
|
||||
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"/>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-3">
|
||||
{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>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{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"
|
||||
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">
|
||||
{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">
|
||||
Pre-release
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground mb-2 leading-relaxed">
|
||||
{formatDescription(release.description)}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center text-xs text-muted-foreground">
|
||||
<span>{new Date(release.pubDate).toLocaleDateString()}</span>
|
||||
{release.assets.length > 0 && (
|
||||
<>
|
||||
<span className="mx-2">•</span>
|
||||
<span>{release.assets.length} asset{release.assets.length !== 1 ? 's' : ''}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{releases && releases.items.length === 0 && !loading && (
|
||||
<Alert>
|
||||
<AlertTitle>No Releases</AlertTitle>
|
||||
<AlertDescription>
|
||||
No releases found.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,350 +0,0 @@
|
||||
import React, {useState, useEffect} from "react";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import {loadLanguage} from '@uiw/codemirror-extensions-langs';
|
||||
import {hyperLink} from '@uiw/codemirror-extensions-hyper-link';
|
||||
import {oneDark} from '@codemirror/theme-one-dark';
|
||||
import {EditorView} from '@codemirror/view';
|
||||
|
||||
interface ConfigCodeEditorProps {
|
||||
content: string;
|
||||
fileName: string;
|
||||
onContentChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function ConfigCodeEditor({content, fileName, onContentChange}: ConfigCodeEditorProps) {
|
||||
function getLanguageName(filename: string): string {
|
||||
if (!filename || typeof filename !== 'string') {
|
||||
return 'text';
|
||||
}
|
||||
const lastDotIndex = filename.lastIndexOf('.');
|
||||
if (lastDotIndex === -1) {
|
||||
return 'text';
|
||||
}
|
||||
const ext = filename.slice(lastDotIndex + 1).toLowerCase();
|
||||
|
||||
switch (ext) {
|
||||
case 'ng':
|
||||
return 'angular';
|
||||
case 'apl':
|
||||
return 'apl';
|
||||
case 'asc':
|
||||
return 'asciiArmor';
|
||||
case 'ast':
|
||||
return 'asterisk';
|
||||
case 'bf':
|
||||
return 'brainfuck';
|
||||
case 'c':
|
||||
return 'c';
|
||||
case 'ceylon':
|
||||
return 'ceylon';
|
||||
case 'clj':
|
||||
return 'clojure';
|
||||
case 'cmake':
|
||||
return 'cmake';
|
||||
case 'cob':
|
||||
case 'cbl':
|
||||
return 'cobol';
|
||||
case 'coffee':
|
||||
return 'coffeescript';
|
||||
case 'lisp':
|
||||
return 'commonLisp';
|
||||
case 'cpp':
|
||||
case 'cc':
|
||||
case 'cxx':
|
||||
return 'cpp';
|
||||
case 'cr':
|
||||
return 'crystal';
|
||||
case 'cs':
|
||||
return 'csharp';
|
||||
case 'css':
|
||||
return 'css';
|
||||
case 'cypher':
|
||||
return 'cypher';
|
||||
case 'd':
|
||||
return 'd';
|
||||
case 'dart':
|
||||
return 'dart';
|
||||
case 'diff':
|
||||
case 'patch':
|
||||
return 'diff';
|
||||
case 'dockerfile':
|
||||
return 'dockerfile';
|
||||
case 'dtd':
|
||||
return 'dtd';
|
||||
case 'dylan':
|
||||
return 'dylan';
|
||||
case 'ebnf':
|
||||
return 'ebnf';
|
||||
case 'ecl':
|
||||
return 'ecl';
|
||||
case 'eiffel':
|
||||
return 'eiffel';
|
||||
case 'elm':
|
||||
return 'elm';
|
||||
case 'erl':
|
||||
return 'erlang';
|
||||
case 'factor':
|
||||
return 'factor';
|
||||
case 'fcl':
|
||||
return 'fcl';
|
||||
case 'fs':
|
||||
return 'forth';
|
||||
case 'f90':
|
||||
case 'for':
|
||||
return 'fortran';
|
||||
case 's':
|
||||
return 'gas';
|
||||
case 'feature':
|
||||
return 'gherkin';
|
||||
case 'go':
|
||||
return 'go';
|
||||
case 'groovy':
|
||||
return 'groovy';
|
||||
case 'hs':
|
||||
return 'haskell';
|
||||
case 'hx':
|
||||
return 'haxe';
|
||||
case 'html':
|
||||
case 'htm':
|
||||
return 'html';
|
||||
case 'http':
|
||||
return 'http';
|
||||
case 'idl':
|
||||
return 'idl';
|
||||
case 'java':
|
||||
return 'java';
|
||||
case 'js':
|
||||
case 'mjs':
|
||||
case 'cjs':
|
||||
return 'javascript';
|
||||
case 'jinja2':
|
||||
case 'j2':
|
||||
return 'jinja2';
|
||||
case 'json':
|
||||
return 'json';
|
||||
case 'jsx':
|
||||
return 'jsx';
|
||||
case 'jl':
|
||||
return 'julia';
|
||||
case 'kt':
|
||||
case 'kts':
|
||||
return 'kotlin';
|
||||
case 'less':
|
||||
return 'less';
|
||||
case 'lezer':
|
||||
return 'lezer';
|
||||
case 'liquid':
|
||||
return 'liquid';
|
||||
case 'litcoffee':
|
||||
return 'livescript';
|
||||
case 'lua':
|
||||
return 'lua';
|
||||
case 'md':
|
||||
return 'markdown';
|
||||
case 'nb':
|
||||
case 'mat':
|
||||
return 'mathematica';
|
||||
case 'mbox':
|
||||
return 'mbox';
|
||||
case 'mmd':
|
||||
return 'mermaid';
|
||||
case 'mrc':
|
||||
return 'mirc';
|
||||
case 'moo':
|
||||
return 'modelica';
|
||||
case 'mscgen':
|
||||
return 'mscgen';
|
||||
case 'm':
|
||||
return 'mumps';
|
||||
case 'sql':
|
||||
return 'mysql';
|
||||
case 'nc':
|
||||
return 'nesC';
|
||||
case 'nginx':
|
||||
return 'nginx';
|
||||
case 'nix':
|
||||
return 'nix';
|
||||
case 'nsi':
|
||||
return 'nsis';
|
||||
case 'nt':
|
||||
return 'ntriples';
|
||||
case 'mm':
|
||||
return 'objectiveCpp';
|
||||
case 'octave':
|
||||
return 'octave';
|
||||
case 'oz':
|
||||
return 'oz';
|
||||
case 'pas':
|
||||
return 'pascal';
|
||||
case 'pl':
|
||||
case 'pm':
|
||||
return 'perl';
|
||||
case 'pgsql':
|
||||
return 'pgsql';
|
||||
case 'php':
|
||||
return 'php';
|
||||
case 'pig':
|
||||
return 'pig';
|
||||
case 'ps1':
|
||||
return 'powershell';
|
||||
case 'properties':
|
||||
return 'properties';
|
||||
case 'proto':
|
||||
return 'protobuf';
|
||||
case 'pp':
|
||||
return 'puppet';
|
||||
case 'py':
|
||||
return 'python';
|
||||
case 'q':
|
||||
return 'q';
|
||||
case 'r':
|
||||
return 'r';
|
||||
case 'rb':
|
||||
return 'ruby';
|
||||
case 'rs':
|
||||
return 'rust';
|
||||
case 'sas':
|
||||
return 'sas';
|
||||
case 'sass':
|
||||
case 'scss':
|
||||
return 'sass';
|
||||
case 'scala':
|
||||
return 'scala';
|
||||
case 'scm':
|
||||
return 'scheme';
|
||||
case 'shader':
|
||||
return 'shader';
|
||||
case 'sh':
|
||||
case 'bash':
|
||||
return 'shell';
|
||||
case 'siv':
|
||||
return 'sieve';
|
||||
case 'st':
|
||||
return 'smalltalk';
|
||||
case 'sol':
|
||||
return 'solidity';
|
||||
case 'solr':
|
||||
return 'solr';
|
||||
case 'rq':
|
||||
return 'sparql';
|
||||
case 'xlsx':
|
||||
case 'ods':
|
||||
case 'csv':
|
||||
return 'spreadsheet';
|
||||
case 'nut':
|
||||
return 'squirrel';
|
||||
case 'tex':
|
||||
return 'stex';
|
||||
case 'styl':
|
||||
return 'stylus';
|
||||
case 'svelte':
|
||||
return 'svelte';
|
||||
case 'swift':
|
||||
return 'swift';
|
||||
case 'tcl':
|
||||
return 'tcl';
|
||||
case 'textile':
|
||||
return 'textile';
|
||||
case 'tiddlywiki':
|
||||
return 'tiddlyWiki';
|
||||
case 'tiki':
|
||||
return 'tiki';
|
||||
case 'toml':
|
||||
return 'toml';
|
||||
case 'troff':
|
||||
return 'troff';
|
||||
case 'tsx':
|
||||
return 'tsx';
|
||||
case 'ttcn':
|
||||
return 'ttcn';
|
||||
case 'ttl':
|
||||
case 'turtle':
|
||||
return 'turtle';
|
||||
case 'ts':
|
||||
return 'typescript';
|
||||
case 'vb':
|
||||
return 'vb';
|
||||
case 'vbs':
|
||||
return 'vbscript';
|
||||
case 'vm':
|
||||
return 'velocity';
|
||||
case 'v':
|
||||
return 'verilog';
|
||||
case 'vhd':
|
||||
case 'vhdl':
|
||||
return 'vhdl';
|
||||
case 'vue':
|
||||
return 'vue';
|
||||
case 'wat':
|
||||
return 'wast';
|
||||
case 'webidl':
|
||||
return 'webIDL';
|
||||
case 'xq':
|
||||
case 'xquery':
|
||||
return 'xQuery';
|
||||
case 'xml':
|
||||
return 'xml';
|
||||
case 'yacas':
|
||||
return 'yacas';
|
||||
case 'yaml':
|
||||
case 'yml':
|
||||
return 'yaml';
|
||||
case 'z80':
|
||||
return 'z80';
|
||||
default:
|
||||
return 'text';
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflowX = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflowX = '';
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflow: 'auto',
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
className="config-codemirror-scroll-wrapper"
|
||||
>
|
||||
<CodeMirror
|
||||
value={content}
|
||||
extensions={[
|
||||
loadLanguage(getLanguageName(fileName || 'untitled.txt') as any) || [],
|
||||
hyperLink,
|
||||
oneDark,
|
||||
EditorView.theme({
|
||||
'&': {
|
||||
backgroundColor: '#09090b !important',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: '#18181b !important',
|
||||
},
|
||||
})
|
||||
]}
|
||||
onChange={(value: any) => onContentChange(value)}
|
||||
theme={undefined}
|
||||
height="100%"
|
||||
basicSetup={{lineNumbers: true}}
|
||||
style={{minHeight: '100%', minWidth: '100%', flex: 1}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,608 +0,0 @@
|
||||
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 {Button} from '@/components/ui/button.tsx';
|
||||
import {ConfigTopbar} from "@/apps/SSH/Config Editor/ConfigTopbar.tsx";
|
||||
import {cn} from '@/lib/utils.ts';
|
||||
import {
|
||||
getConfigEditorRecent,
|
||||
getConfigEditorPinned,
|
||||
getConfigEditorShortcuts,
|
||||
addConfigEditorRecent,
|
||||
removeConfigEditorRecent,
|
||||
addConfigEditorPinned,
|
||||
removeConfigEditorPinned,
|
||||
addConfigEditorShortcut,
|
||||
removeConfigEditorShortcut,
|
||||
readSSHFile,
|
||||
writeSSHFile,
|
||||
getSSHStatus,
|
||||
connectSSH
|
||||
} from '@/apps/SSH/ssh-axios.ts';
|
||||
|
||||
interface Tab {
|
||||
id: string | number;
|
||||
title: string;
|
||||
fileName: string;
|
||||
content: string;
|
||||
isSSH?: boolean;
|
||||
sshSessionId?: string;
|
||||
filePath?: string;
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
success?: string;
|
||||
dirty?: boolean;
|
||||
}
|
||||
|
||||
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 function ConfigEditor({onSelectView}: { onSelectView: (view: string) => void }): React.ReactElement {
|
||||
const [tabs, setTabs] = useState<Tab[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<string | number>('home');
|
||||
const [recent, setRecent] = useState<any[]>([]);
|
||||
const [pinned, setPinned] = useState<any[]>([]);
|
||||
const [shortcuts, setShortcuts] = useState<any[]>([]);
|
||||
|
||||
const [currentHost, setCurrentHost] = useState<SSHHost | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const sidebarRef = useRef<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentHost) {
|
||||
fetchHomeData();
|
||||
} else {
|
||||
setRecent([]);
|
||||
setPinned([]);
|
||||
setShortcuts([]);
|
||||
}
|
||||
}, [currentHost]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'home' && currentHost) {
|
||||
fetchHomeData();
|
||||
}
|
||||
}, [activeTab, currentHost]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'home' && currentHost) {
|
||||
const interval = setInterval(() => {
|
||||
fetchHomeData();
|
||||
}, 2000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [activeTab, currentHost]);
|
||||
|
||||
async function fetchHomeData() {
|
||||
if (!currentHost) return;
|
||||
|
||||
try {
|
||||
const homeDataPromise = Promise.all([
|
||||
getConfigEditorRecent(currentHost.id),
|
||||
getConfigEditorPinned(currentHost.id),
|
||||
getConfigEditorShortcuts(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 recentWithPinnedStatus = (recentRes || []).map(file => ({
|
||||
...file,
|
||||
type: 'file',
|
||||
isPinned: (pinnedRes || []).some(pinnedFile =>
|
||||
pinnedFile.path === file.path && pinnedFile.name === file.name
|
||||
)
|
||||
}));
|
||||
|
||||
const pinnedWithType = (pinnedRes || []).map(file => ({
|
||||
...file,
|
||||
type: 'file'
|
||||
}));
|
||||
|
||||
setRecent(recentWithPinnedStatus);
|
||||
setPinned(pinnedWithType);
|
||||
setShortcuts((shortcutsRes || []).map(shortcut => ({
|
||||
...shortcut,
|
||||
type: 'directory'
|
||||
})));
|
||||
} catch (err: any) {
|
||||
}
|
||||
}
|
||||
|
||||
const formatErrorMessage = (err: any, defaultMessage: string): string => {
|
||||
if (typeof err === 'object' && err !== null && 'response' in err) {
|
||||
const axiosErr = err as any;
|
||||
if (axiosErr.response?.status === 403) {
|
||||
return `Permission denied. ${defaultMessage}. Check the Docker logs for detailed error information.`;
|
||||
} else if (axiosErr.response?.status === 500) {
|
||||
const backendError = axiosErr.response?.data?.error || 'Internal server error occurred';
|
||||
return `Server Error (500): ${backendError}. Check the Docker logs for detailed error information.`;
|
||||
} else if (axiosErr.response?.data?.error) {
|
||||
const backendError = axiosErr.response.data.error;
|
||||
return `${axiosErr.response?.status ? `Error ${axiosErr.response.status}: ` : ''}${backendError}. Check the Docker logs for detailed error information.`;
|
||||
} else {
|
||||
return `Request failed with status code ${axiosErr.response?.status || 'unknown'}. Check the Docker logs for detailed error information.`;
|
||||
}
|
||||
} else if (err instanceof Error) {
|
||||
return `${err.message}. Check the Docker logs for detailed error information.`;
|
||||
} else {
|
||||
return `${defaultMessage}. Check the Docker logs for detailed error information.`;
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenFile = async (file: any) => {
|
||||
const tabId = file.path;
|
||||
|
||||
if (!tabs.find(t => t.id === tabId)) {
|
||||
const currentSshSessionId = currentHost?.id.toString();
|
||||
|
||||
setTabs([...tabs, {
|
||||
id: tabId,
|
||||
title: file.name,
|
||||
fileName: file.name,
|
||||
content: '',
|
||||
filePath: file.path,
|
||||
isSSH: true,
|
||||
sshSessionId: currentSshSessionId,
|
||||
loading: true
|
||||
}]);
|
||||
try {
|
||||
const res = await readSSHFile(currentSshSessionId, file.path);
|
||||
setTabs(tabs => tabs.map(t => t.id === tabId ? {
|
||||
...t,
|
||||
content: res.content,
|
||||
loading: false,
|
||||
error: undefined
|
||||
} : t));
|
||||
await addConfigEditorRecent({
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
isSSH: true,
|
||||
sshSessionId: currentSshSessionId,
|
||||
hostId: currentHost?.id
|
||||
});
|
||||
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));
|
||||
}
|
||||
}
|
||||
setActiveTab(tabId);
|
||||
};
|
||||
|
||||
const handleRemoveRecent = async (file: any) => {
|
||||
try {
|
||||
await removeConfigEditorRecent({
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
isSSH: true,
|
||||
sshSessionId: file.sshSessionId,
|
||||
hostId: currentHost?.id
|
||||
});
|
||||
fetchHomeData();
|
||||
} catch (err) {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePinFile = async (file: any) => {
|
||||
try {
|
||||
await addConfigEditorPinned({
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
isSSH: true,
|
||||
sshSessionId: file.sshSessionId,
|
||||
hostId: currentHost?.id
|
||||
});
|
||||
fetchHomeData();
|
||||
if (sidebarRef.current && sidebarRef.current.fetchFiles) {
|
||||
sidebarRef.current.fetchFiles();
|
||||
}
|
||||
} catch (err) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnpinFile = async (file: any) => {
|
||||
try {
|
||||
await removeConfigEditorPinned({
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
isSSH: true,
|
||||
sshSessionId: file.sshSessionId,
|
||||
hostId: currentHost?.id
|
||||
});
|
||||
fetchHomeData();
|
||||
if (sidebarRef.current && sidebarRef.current.fetchFiles) {
|
||||
sidebarRef.current.fetchFiles();
|
||||
}
|
||||
} catch (err) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenShortcut = async (shortcut: any) => {
|
||||
if (sidebarRef.current?.isOpeningShortcut) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sidebarRef.current && sidebarRef.current.openFolder) {
|
||||
try {
|
||||
sidebarRef.current.isOpeningShortcut = true;
|
||||
|
||||
const normalizedPath = shortcut.path.startsWith('/') ? shortcut.path : `/${shortcut.path}`;
|
||||
|
||||
await sidebarRef.current.openFolder(currentHost, normalizedPath);
|
||||
} catch (err) {
|
||||
} finally {
|
||||
if (sidebarRef.current) {
|
||||
sidebarRef.current.isOpeningShortcut = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddShortcut = async (folderPath: string) => {
|
||||
try {
|
||||
const name = folderPath.split('/').pop() || folderPath;
|
||||
await addConfigEditorShortcut({
|
||||
name,
|
||||
path: folderPath,
|
||||
isSSH: true,
|
||||
sshSessionId: currentHost?.id.toString(),
|
||||
hostId: currentHost?.id
|
||||
});
|
||||
fetchHomeData();
|
||||
} catch (err) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveShortcut = async (shortcut: any) => {
|
||||
try {
|
||||
await removeConfigEditorShortcut({
|
||||
name: shortcut.name,
|
||||
path: shortcut.path,
|
||||
isSSH: true,
|
||||
sshSessionId: currentHost?.id.toString(),
|
||||
hostId: currentHost?.id
|
||||
});
|
||||
fetchHomeData();
|
||||
} catch (err) {
|
||||
}
|
||||
};
|
||||
|
||||
const closeTab = (tabId: string | number) => {
|
||||
const idx = tabs.findIndex(t => t.id === tabId);
|
||||
const newTabs = tabs.filter(t => t.id !== tabId);
|
||||
setTabs(newTabs);
|
||||
if (activeTab === tabId) {
|
||||
if (newTabs.length > 0) setActiveTab(newTabs[Math.max(0, idx - 1)].id);
|
||||
else setActiveTab('home');
|
||||
}
|
||||
if (currentHost) {
|
||||
fetchHomeData();
|
||||
}
|
||||
};
|
||||
|
||||
const setTabContent = (tabId: string | number, content: string) => {
|
||||
setTabs(tabs => tabs.map(t => t.id === tabId ? {
|
||||
...t,
|
||||
content,
|
||||
dirty: true,
|
||||
error: undefined,
|
||||
success: undefined
|
||||
} : t));
|
||||
};
|
||||
|
||||
const handleSave = async (tab: Tab) => {
|
||||
if (isSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
if (!tab.sshSessionId) {
|
||||
throw new Error('No SSH session ID available');
|
||||
}
|
||||
|
||||
if (!tab.filePath) {
|
||||
throw new Error('No file path available');
|
||||
}
|
||||
|
||||
if (!currentHost?.id) {
|
||||
throw new Error('No current host available');
|
||||
}
|
||||
|
||||
try {
|
||||
const statusPromise = getSSHStatus(tab.sshSessionId);
|
||||
const statusTimeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('SSH status check timed out')), 10000)
|
||||
);
|
||||
|
||||
const status = await Promise.race([statusPromise, statusTimeoutPromise]);
|
||||
|
||||
if (!status.connected) {
|
||||
const connectPromise = connectSSH(tab.sshSessionId, {
|
||||
ip: currentHost.ip,
|
||||
port: currentHost.port,
|
||||
username: currentHost.username,
|
||||
password: currentHost.password,
|
||||
sshKey: currentHost.key,
|
||||
keyPassword: currentHost.keyPassword
|
||||
});
|
||||
const connectTimeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('SSH reconnection timed out')), 15000)
|
||||
);
|
||||
|
||||
await Promise.race([connectPromise, connectTimeoutPromise]);
|
||||
}
|
||||
} catch (statusErr) {
|
||||
}
|
||||
|
||||
const savePromise = writeSSHFile(tab.sshSessionId, tab.filePath, tab.content);
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => {
|
||||
reject(new Error('Save operation timed out'));
|
||||
}, 30000)
|
||||
);
|
||||
|
||||
const result = await Promise.race([savePromise, timeoutPromise]);
|
||||
setTabs(tabs => tabs.map(t => t.id === tab.id ? {
|
||||
...t,
|
||||
dirty: false,
|
||||
success: 'File saved successfully'
|
||||
} : t));
|
||||
|
||||
setTimeout(() => {
|
||||
setTabs(tabs => tabs.map(t => t.id === tab.id ? {...t, success: undefined} : t));
|
||||
}, 3000);
|
||||
|
||||
Promise.allSettled([
|
||||
(async () => {
|
||||
try {
|
||||
await addConfigEditorRecent({
|
||||
name: tab.fileName,
|
||||
path: tab.filePath,
|
||||
isSSH: true,
|
||||
sshSessionId: tab.sshSessionId,
|
||||
hostId: currentHost.id
|
||||
});
|
||||
} catch (recentErr) {
|
||||
}
|
||||
})(),
|
||||
(async () => {
|
||||
try {
|
||||
await fetchHomeData();
|
||||
} catch (refreshErr) {
|
||||
}
|
||||
})()
|
||||
]).then(() => {
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
let errorMessage = formatErrorMessage(err, 'Cannot save file');
|
||||
|
||||
if (errorMessage.includes('timed out') || errorMessage.includes('timeout')) {
|
||||
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);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleHostChange = (host: SSHHost | null) => {
|
||||
setCurrentHost(host);
|
||||
setTabs([]);
|
||||
setActiveTab('home');
|
||||
};
|
||||
|
||||
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}
|
||||
onOpenFile={handleOpenFile}
|
||||
tabs={tabs}
|
||||
ref={sidebarRef}
|
||||
onHostChange={handleHostChange}
|
||||
/>
|
||||
</div>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 256,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#09090b'
|
||||
}}>
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold text-white mb-2">Connect to a Server</h2>
|
||||
<p className="text-muted-foreground">Select a server from the sidebar to start editing files</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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}
|
||||
onOpenFile={handleOpenFile}
|
||||
tabs={tabs}
|
||||
ref={sidebarRef}
|
||||
onHostChange={handleHostChange}
|
||||
/>
|
||||
</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>
|
||||
{/* 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={{
|
||||
position: 'absolute',
|
||||
top: 44,
|
||||
left: 256,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
overflow: 'hidden',
|
||||
zIndex: 10,
|
||||
background: '#101014',
|
||||
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>
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,594 +0,0 @@
|
||||
import React, {useEffect, useState, useRef, forwardRef, useImperativeHandle} from 'react';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel, SidebarMenu, SidebarMenuItem,
|
||||
SidebarProvider
|
||||
} from '@/components/ui/sidebar.tsx';
|
||||
import {Separator} from '@/components/ui/separator.tsx';
|
||||
import {CornerDownLeft, Folder, File, Server, ArrowUp, Pin} from 'lucide-react';
|
||||
import {ScrollArea} from '@/components/ui/scroll-area.tsx';
|
||||
import {cn} from '@/lib/utils.ts';
|
||||
import {Input} from '@/components/ui/input.tsx';
|
||||
import {Button} from '@/components/ui/button.tsx';
|
||||
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from '@/components/ui/accordion.tsx';
|
||||
import {
|
||||
getSSHHosts,
|
||||
listSSHFiles,
|
||||
connectSSH,
|
||||
getSSHStatus,
|
||||
getConfigEditorPinned,
|
||||
addConfigEditorPinned,
|
||||
removeConfigEditorPinned
|
||||
} from '@/apps/SSH/ssh-axios.ts';
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder: string;
|
||||
tags: string[];
|
||||
pin: boolean;
|
||||
authType: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableConfigEditor: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: any[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
|
||||
{onSelectView, onOpenFile, tabs, onHostChange}: {
|
||||
onSelectView: (view: string) => void;
|
||||
onOpenFile: (file: any) => void;
|
||||
tabs: any[];
|
||||
onHostChange?: (host: SSHHost | null) => void;
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const [sshConnections, setSSHConnections] = useState<SSHHost[]>([]);
|
||||
const [loadingSSH, setLoadingSSH] = useState(false);
|
||||
const [errorSSH, setErrorSSH] = useState<string | undefined>(undefined);
|
||||
const [view, setView] = useState<'servers' | 'files'>('servers');
|
||||
const [activeServer, setActiveServer] = useState<SSHHost | null>(null);
|
||||
const [currentPath, setCurrentPath] = useState('/');
|
||||
const [files, setFiles] = useState<any[]>([]);
|
||||
const pathInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [fileSearch, setFileSearch] = useState('');
|
||||
const [debouncedFileSearch, setDebouncedFileSearch] = useState('');
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedSearch(search), 200);
|
||||
return () => clearTimeout(handler);
|
||||
}, [search]);
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedFileSearch(fileSearch), 200);
|
||||
return () => clearTimeout(handler);
|
||||
}, [fileSearch]);
|
||||
|
||||
const [sshSessionId, setSshSessionId] = useState<string | null>(null);
|
||||
const [filesLoading, setFilesLoading] = useState(false);
|
||||
const [filesError, setFilesError] = useState<string | null>(null);
|
||||
const [connectingSSH, setConnectingSSH] = useState(false);
|
||||
const [connectionCache, setConnectionCache] = useState<Record<string, {
|
||||
sessionId: string;
|
||||
timestamp: number
|
||||
}>>({});
|
||||
const [fetchingFiles, setFetchingFiles] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSSH();
|
||||
}, []);
|
||||
|
||||
async function fetchSSH() {
|
||||
setLoadingSSH(true);
|
||||
setErrorSSH(undefined);
|
||||
try {
|
||||
const hosts = await getSSHHosts();
|
||||
const configEditorHosts = hosts.filter(host => host.enableConfigEditor);
|
||||
|
||||
if (configEditorHosts.length > 0) {
|
||||
const firstHost = configEditorHosts[0];
|
||||
}
|
||||
|
||||
setSSHConnections(configEditorHosts);
|
||||
} catch (err: any) {
|
||||
setErrorSSH('Failed to load SSH connections');
|
||||
} finally {
|
||||
setLoadingSSH(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function connectToSSH(server: SSHHost): Promise<string | null> {
|
||||
const sessionId = server.id.toString();
|
||||
|
||||
const cached = connectionCache[sessionId];
|
||||
if (cached && Date.now() - cached.timestamp < 30000) {
|
||||
setSshSessionId(cached.sessionId);
|
||||
return cached.sessionId;
|
||||
}
|
||||
|
||||
if (connectingSSH) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setConnectingSSH(true);
|
||||
|
||||
try {
|
||||
if (!server.password && !server.key) {
|
||||
setFilesError('No authentication credentials available for this SSH host');
|
||||
return null;
|
||||
}
|
||||
|
||||
const connectionConfig = {
|
||||
ip: server.ip,
|
||||
port: server.port,
|
||||
username: server.username,
|
||||
password: server.password,
|
||||
sshKey: server.key,
|
||||
keyPassword: server.keyPassword,
|
||||
};
|
||||
|
||||
await connectSSH(sessionId, connectionConfig);
|
||||
|
||||
setSshSessionId(sessionId);
|
||||
|
||||
setConnectionCache(prev => ({
|
||||
...prev,
|
||||
[sessionId]: {sessionId, timestamp: Date.now()}
|
||||
}));
|
||||
|
||||
return sessionId;
|
||||
} catch (err: any) {
|
||||
setFilesError(err?.response?.data?.error || 'Failed to connect to SSH');
|
||||
setSshSessionId(null);
|
||||
return null;
|
||||
} finally {
|
||||
setConnectingSSH(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchFiles() {
|
||||
if (fetchingFiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFetchingFiles(true);
|
||||
setFiles([]);
|
||||
setFilesLoading(true);
|
||||
setFilesError(null);
|
||||
|
||||
try {
|
||||
let pinnedFiles: any[] = [];
|
||||
try {
|
||||
if (activeServer) {
|
||||
pinnedFiles = await getConfigEditorPinned(activeServer.id);
|
||||
}
|
||||
} catch (err) {
|
||||
}
|
||||
|
||||
if (activeServer && sshSessionId) {
|
||||
let res: any[] = [];
|
||||
|
||||
try {
|
||||
const status = await getSSHStatus(sshSessionId);
|
||||
if (!status.connected) {
|
||||
const newSessionId = await connectToSSH(activeServer);
|
||||
if (newSessionId) {
|
||||
setSshSessionId(newSessionId);
|
||||
res = await listSSHFiles(newSessionId, currentPath);
|
||||
} else {
|
||||
throw new Error('Failed to reconnect SSH session');
|
||||
}
|
||||
} else {
|
||||
res = await listSSHFiles(sshSessionId, currentPath);
|
||||
}
|
||||
} catch (sessionErr) {
|
||||
const newSessionId = await connectToSSH(activeServer);
|
||||
if (newSessionId) {
|
||||
setSshSessionId(newSessionId);
|
||||
res = await listSSHFiles(newSessionId, currentPath);
|
||||
} else {
|
||||
throw sessionErr;
|
||||
}
|
||||
}
|
||||
|
||||
const processedFiles = (res || []).map((f: any) => {
|
||||
const filePath = currentPath + (currentPath.endsWith('/') ? '' : '/') + f.name;
|
||||
const isPinned = pinnedFiles.some(pinned => pinned.path === filePath);
|
||||
return {
|
||||
...f,
|
||||
path: filePath,
|
||||
isPinned,
|
||||
isSSH: true,
|
||||
sshSessionId: sshSessionId
|
||||
};
|
||||
});
|
||||
|
||||
setFiles(processedFiles);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setFiles([]);
|
||||
setFilesError(err?.response?.data?.error || err?.message || 'Failed to list files');
|
||||
} finally {
|
||||
setFilesLoading(false);
|
||||
setFetchingFiles(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (view === 'files' && activeServer && sshSessionId && !connectingSSH && !fetchingFiles) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
fetchFiles();
|
||||
}, 100);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [currentPath, view, activeServer, sshSessionId]);
|
||||
|
||||
async function handleSelectServer(server: SSHHost) {
|
||||
if (connectingSSH) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFetchingFiles(false);
|
||||
setFilesLoading(false);
|
||||
setFilesError(null);
|
||||
setFiles([]);
|
||||
|
||||
setActiveServer(server);
|
||||
setCurrentPath(server.defaultPath || '/');
|
||||
setView('files');
|
||||
|
||||
const sessionId = await connectToSSH(server);
|
||||
if (sessionId) {
|
||||
setSshSessionId(sessionId);
|
||||
if (onHostChange) {
|
||||
onHostChange(server);
|
||||
}
|
||||
} else {
|
||||
w
|
||||
setView('servers');
|
||||
setActiveServer(null);
|
||||
}
|
||||
}
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
openFolder: async (server: SSHHost, path: string) => {
|
||||
if (connectingSSH || fetchingFiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeServer?.id === server.id && currentPath === path) {
|
||||
setTimeout(() => fetchFiles(), 100);
|
||||
return;
|
||||
}
|
||||
|
||||
setFetchingFiles(false);
|
||||
setFilesLoading(false);
|
||||
setFilesError(null);
|
||||
setFiles([]);
|
||||
|
||||
setActiveServer(server);
|
||||
setCurrentPath(path);
|
||||
setView('files');
|
||||
|
||||
if (!sshSessionId || activeServer?.id !== server.id) {
|
||||
const sessionId = await connectToSSH(server);
|
||||
if (sessionId) {
|
||||
setSshSessionId(sessionId);
|
||||
if (onHostChange && activeServer?.id !== server.id) {
|
||||
onHostChange(server);
|
||||
}
|
||||
} else {
|
||||
setView('servers');
|
||||
setActiveServer(null);
|
||||
}
|
||||
} else {
|
||||
if (onHostChange && activeServer?.id !== server.id) {
|
||||
onHostChange(server);
|
||||
}
|
||||
}
|
||||
},
|
||||
fetchFiles: () => {
|
||||
if (activeServer && sshSessionId) {
|
||||
fetchFiles();
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (pathInputRef.current) {
|
||||
pathInputRef.current.scrollLeft = pathInputRef.current.scrollWidth;
|
||||
}
|
||||
}, [currentPath]);
|
||||
|
||||
const sshByFolder: Record<string, SSHHost[]> = {};
|
||||
sshConnections.forEach(conn => {
|
||||
const folder = conn.folder && conn.folder.trim() ? conn.folder : 'No Folder';
|
||||
if (!sshByFolder[folder]) sshByFolder[folder] = [];
|
||||
sshByFolder[folder].push(conn);
|
||||
});
|
||||
|
||||
const sortedFolders = Object.keys(sshByFolder);
|
||||
if (sortedFolders.includes('No Folder')) {
|
||||
sortedFolders.splice(sortedFolders.indexOf('No Folder'), 1);
|
||||
sortedFolders.unshift('No Folder');
|
||||
}
|
||||
|
||||
const filteredSshByFolder: Record<string, SSHHost[]> = {};
|
||||
Object.entries(sshByFolder).forEach(([folder, hosts]) => {
|
||||
filteredSshByFolder[folder] = hosts.filter(conn => {
|
||||
const q = debouncedSearch.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return (conn.name || '').toLowerCase().includes(q) || (conn.ip || '').toLowerCase().includes(q) ||
|
||||
(conn.username || '').toLowerCase().includes(q) || (conn.folder || '').toLowerCase().includes(q) ||
|
||||
(conn.tags || []).join(' ').toLowerCase().includes(q);
|
||||
});
|
||||
});
|
||||
|
||||
const filteredFiles = files.filter(file => {
|
||||
const q = debouncedFileSearch.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return file.name.toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<Sidebar style={{height: '100vh', maxHeight: '100vh', overflow: 'hidden'}}>
|
||||
<SidebarContent style={{height: '100vh', maxHeight: '100vh', overflow: 'hidden'}}>
|
||||
<SidebarGroup className="flex flex-col flex-grow h-full overflow-hidden">
|
||||
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
|
||||
Termix / Config
|
||||
</SidebarGroupLabel>
|
||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
||||
<SidebarGroupContent className="flex flex-col flex-grow min-h-0">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem key={"Homepage"}>
|
||||
<Button className="w-full mt-2 mb-2 h-8" onClick={() => onSelectView("homepage")}
|
||||
variant="outline">
|
||||
<CornerDownLeft/>
|
||||
Return
|
||||
</Button>
|
||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
<div
|
||||
className="flex-1 w-full flex flex-col rounded-md bg-[#09090b] border border-[#434345] overflow-hidden p-0 relative min-h-0 mt-1">
|
||||
{view === 'servers' && (
|
||||
<>
|
||||
<div
|
||||
className="w-full px-2 pt-2 pb-2 bg-[#09090b] z-10 border-b border-[#23232a]">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Search hosts by name, username, IP, folder, tags..."
|
||||
className="w-full h-8 text-sm bg-[#18181b] border border-[#23232a] text-white placeholder:text-muted-foreground rounded"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<ScrollArea className="flex-1 w-full h-full"
|
||||
style={{height: '100%', maxHeight: '100%'}}>
|
||||
<div className="flex flex-col h-full">
|
||||
<div
|
||||
className="w-full flex-grow overflow-hidden p-0 m-0 relative flex flex-col min-h-0">
|
||||
<div style={{display: 'flex', justifyContent: 'center'}}>
|
||||
<Separator className="w-full h-px bg-[#434345] my-2"
|
||||
style={{maxWidth: 213, margin: '0 auto'}}/>
|
||||
</div>
|
||||
<div className="mx-auto" style={{maxWidth: '213px', width: '100%'}}>
|
||||
<div className="flex-1 min-h-0">
|
||||
<Accordion type="multiple" className="w-full"
|
||||
value={sortedFolders}>
|
||||
{sortedFolders.map((folder, idx) => (
|
||||
<React.Fragment key={folder}>
|
||||
<AccordionItem value={folder}
|
||||
className="mt-0 w-full !border-b-transparent">
|
||||
<AccordionTrigger
|
||||
className="text-base font-semibold rounded-t-none py-2 w-full">{folder}</AccordionTrigger>
|
||||
<AccordionContent
|
||||
className="flex flex-col gap-1 pb-2 pt-1 w-full">
|
||||
{filteredSshByFolder[folder].map(conn => (
|
||||
<Button
|
||||
key={conn.id}
|
||||
variant="outline"
|
||||
className="w-full h-10 px-2 bg-[#18181b] border border-[#434345] hover:bg-[#2d2d30] transition-colors text-left justify-start"
|
||||
onClick={() => handleSelectServer(conn)}
|
||||
>
|
||||
<div
|
||||
className="flex items-center w-full">
|
||||
{conn.pin && <Pin
|
||||
className="w-0.5 h-0.5 text-yellow-400 mr-1 flex-shrink-0"/>}
|
||||
<span
|
||||
className="font-medium truncate">{conn.name || conn.ip}</span>
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
{idx < sortedFolders.length - 1 && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<Separator
|
||||
className="h-px bg-[#434345] my-1"
|
||||
style={{width: 213}}/>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</>
|
||||
)}
|
||||
{view === 'files' && activeServer && (
|
||||
<div className="flex flex-col h-full w-full" style={{maxWidth: 260}}>
|
||||
<div
|
||||
className="flex items-center gap-2 px-2 py-2 border-b border-[#23232a] bg-[#18181b] z-20"
|
||||
style={{maxWidth: 260}}>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-8 w-8 bg-[#18181b] border border-[#23232a] rounded-md hover:bg-[#2d2d30] focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
onClick={() => {
|
||||
let path = currentPath;
|
||||
if (path && path !== '/' && path !== '') {
|
||||
if (path.endsWith('/')) path = path.slice(0, -1);
|
||||
const lastSlash = path.lastIndexOf('/');
|
||||
if (lastSlash > 0) {
|
||||
setCurrentPath(path.slice(0, lastSlash));
|
||||
} else {
|
||||
setCurrentPath('/');
|
||||
}
|
||||
} else {
|
||||
setView('servers');
|
||||
if (onHostChange) {
|
||||
onHostChange(null);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ArrowUp className="w-4 h-4"/>
|
||||
</Button>
|
||||
<Input ref={pathInputRef} value={currentPath}
|
||||
onChange={e => setCurrentPath(e.target.value)}
|
||||
className="flex-1 bg-[#18181b] border border-[#434345] text-white truncate rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring hover:border-[#5a5a5d]"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-2 py-2 border-b border-[#23232a] bg-[#18181b]">
|
||||
<Input
|
||||
placeholder="Search files and folders..."
|
||||
className="w-full h-7 text-sm bg-[#23232a] border border-[#434345] text-white placeholder:text-muted-foreground rounded"
|
||||
autoComplete="off"
|
||||
value={fileSearch}
|
||||
onChange={e => setFileSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 w-full h-full bg-[#09090b] border-t border-[#23232a]">
|
||||
<ScrollArea className="w-full h-full bg-[#09090b]" style={{
|
||||
height: '100%',
|
||||
maxHeight: '100%',
|
||||
paddingRight: 8,
|
||||
scrollbarGutter: 'stable',
|
||||
background: '#09090b'
|
||||
}}>
|
||||
<div className="p-2 pr-2">
|
||||
{connectingSSH || filesLoading ? (
|
||||
<div className="text-xs text-muted-foreground">Loading...</div>
|
||||
) : filesError ? (
|
||||
<div className="text-xs text-red-500">{filesError}</div>
|
||||
) : filteredFiles.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground">No files or
|
||||
folders found.</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{filteredFiles.map((item: any) => {
|
||||
const isOpen = (tabs || []).some((t: any) => t.id === item.path);
|
||||
return (
|
||||
<div
|
||||
key={item.path}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 bg-[#18181b] border border-[#23232a] rounded group max-w-full",
|
||||
isOpen && "opacity-60 cursor-not-allowed pointer-events-none"
|
||||
)}
|
||||
style={{maxWidth: 220, marginBottom: 8}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
|
||||
onClick={() => !isOpen && (item.type === 'directory' ? setCurrentPath(item.path) : onOpenFile({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
isSSH: item.isSSH,
|
||||
sshSessionId: item.sshSessionId
|
||||
}))}
|
||||
>
|
||||
{item.type === 'directory' ?
|
||||
<Folder
|
||||
className="w-4 h-4 text-blue-400"/> :
|
||||
<File
|
||||
className="w-4 h-4 text-muted-foreground"/>}
|
||||
<span
|
||||
className="text-sm text-white truncate max-w-[120px]">{item.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{item.type === 'file' && (
|
||||
<Button size="icon" variant="ghost"
|
||||
className="h-7 w-7"
|
||||
disabled={isOpen}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
if (item.isPinned) {
|
||||
await removeConfigEditorPinned({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
hostId: activeServer?.id,
|
||||
isSSH: true,
|
||||
sshSessionId: activeServer?.id.toString()
|
||||
});
|
||||
setFiles(files.map(f =>
|
||||
f.path === item.path ? {
|
||||
...f,
|
||||
isPinned: false
|
||||
} : f
|
||||
));
|
||||
} else {
|
||||
await addConfigEditorPinned({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
hostId: activeServer?.id,
|
||||
isSSH: true,
|
||||
sshSessionId: activeServer?.id.toString()
|
||||
});
|
||||
setFiles(files.map(f =>
|
||||
f.path === item.path ? {
|
||||
...f,
|
||||
isPinned: true
|
||||
} : f
|
||||
));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to pin/unpin file:', err);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Pin
|
||||
className={`w-1 h-1 ${item.isPinned ? 'text-yellow-400 fill-current' : 'text-muted-foreground'}`}/>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
</SidebarProvider>
|
||||
);
|
||||
});
|
||||
export {ConfigEditorSidebar};
|
||||
@@ -1,149 +0,0 @@
|
||||
import React from 'react';
|
||||
import {Button} from '@/components/ui/button.tsx';
|
||||
import {Card} from '@/components/ui/card.tsx';
|
||||
import {Separator} from '@/components/ui/separator.tsx';
|
||||
import {Plus, Folder, File, Star, Trash2, Edit, Link2, Server, Pin} from 'lucide-react';
|
||||
|
||||
interface SSHConnection {
|
||||
id: string;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
isPinned?: boolean;
|
||||
}
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
type: 'file' | 'directory' | 'link';
|
||||
path: string;
|
||||
isStarred?: boolean;
|
||||
}
|
||||
|
||||
interface ConfigFileSidebarViewerProps {
|
||||
sshConnections: SSHConnection[];
|
||||
onAddSSH: () => void;
|
||||
onConnectSSH: (conn: SSHConnection) => void;
|
||||
onEditSSH: (conn: SSHConnection) => void;
|
||||
onDeleteSSH: (conn: SSHConnection) => void;
|
||||
onPinSSH: (conn: SSHConnection) => void;
|
||||
currentPath: string;
|
||||
files: FileItem[];
|
||||
onOpenFile: (file: FileItem) => void;
|
||||
onOpenFolder: (folder: FileItem) => void;
|
||||
onStarFile: (file: FileItem) => void;
|
||||
onDeleteFile: (file: FileItem) => void;
|
||||
isLoading?: boolean;
|
||||
error?: string;
|
||||
isSSHMode: boolean;
|
||||
onSwitchToLocal: () => void;
|
||||
onSwitchToSSH: (conn: SSHConnection) => void;
|
||||
currentSSH?: SSHConnection;
|
||||
}
|
||||
|
||||
export function ConfigFileSidebarViewer({
|
||||
sshConnections,
|
||||
onAddSSH,
|
||||
onConnectSSH,
|
||||
onEditSSH,
|
||||
onDeleteSSH,
|
||||
onPinSSH,
|
||||
currentPath,
|
||||
files,
|
||||
onOpenFile,
|
||||
onOpenFolder,
|
||||
onStarFile,
|
||||
onDeleteFile,
|
||||
isLoading,
|
||||
error,
|
||||
isSSHMode,
|
||||
onSwitchToLocal,
|
||||
onSwitchToSSH,
|
||||
currentSSH,
|
||||
}: ConfigFileSidebarViewerProps) {
|
||||
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
|
||||
className="text-xs text-muted-foreground font-semibold">{isSSHMode ? 'SSH Path' : 'Local Path'}</span>
|
||||
<span className="text-xs text-white truncate">{currentPath}</span>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="text-xs text-muted-foreground">Loading...</div>
|
||||
) : error ? (
|
||||
<div className="text-xs text-red-500">{error}</div>
|
||||
) : (
|
||||
<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">
|
||||
<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"/> :
|
||||
<File className="w-4 h-4 text-muted-foreground"/>}
|
||||
<span className="text-sm text-white truncate">{item.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7"
|
||||
onClick={() => onStarFile(item)}>
|
||||
<Pin
|
||||
className={`w-4 h-4 ${item.isStarred ? 'text-yellow-400' : 'text-muted-foreground'}`}/>
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7"
|
||||
onClick={() => onDeleteFile(item)}>
|
||||
<Trash2 className="w-4 h-4 text-red-500"/>
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
{files.length === 0 &&
|
||||
<div className="text-xs text-muted-foreground">No files or folders found.</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
import React from 'react';
|
||||
import {Button} from '@/components/ui/button.tsx';
|
||||
import {Trash2, Folder, File, Plus, Pin} from 'lucide-react';
|
||||
import {Tabs, TabsList, TabsTrigger, TabsContent} from '@/components/ui/tabs.tsx';
|
||||
import {Input} from '@/components/ui/input.tsx';
|
||||
import {useState} from 'react';
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
path: string;
|
||||
isPinned?: boolean;
|
||||
type: 'file' | 'directory';
|
||||
sshSessionId?: string;
|
||||
}
|
||||
|
||||
interface ShortcutItem {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface ConfigHomeViewProps {
|
||||
recent: FileItem[];
|
||||
pinned: FileItem[];
|
||||
shortcuts: ShortcutItem[];
|
||||
onOpenFile: (file: FileItem) => void;
|
||||
onRemoveRecent: (file: FileItem) => void;
|
||||
onPinFile: (file: FileItem) => void;
|
||||
onUnpinFile: (file: FileItem) => void;
|
||||
onOpenShortcut: (shortcut: ShortcutItem) => void;
|
||||
onRemoveShortcut: (shortcut: ShortcutItem) => void;
|
||||
onAddShortcut: (path: string) => void;
|
||||
}
|
||||
|
||||
export function ConfigHomeView({
|
||||
recent,
|
||||
pinned,
|
||||
shortcuts,
|
||||
onOpenFile,
|
||||
onRemoveRecent,
|
||||
onPinFile,
|
||||
onUnpinFile,
|
||||
onOpenShortcut,
|
||||
onRemoveShortcut,
|
||||
onAddShortcut
|
||||
}: ConfigHomeViewProps) {
|
||||
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">
|
||||
<div
|
||||
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
|
||||
onClick={() => onOpenFile(file)}
|
||||
>
|
||||
{file.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"/>
|
||||
}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-white break-words leading-tight">
|
||||
{file.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{onPin && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-1.5 bg-[#23232a] hover:bg-[#2d2d30] rounded-md"
|
||||
onClick={onPin}
|
||||
>
|
||||
<Pin
|
||||
className={`w-3 h-3 ${isPinned ? 'text-yellow-400 fill-current' : 'text-muted-foreground'}`}/>
|
||||
</Button>
|
||||
)}
|
||||
{onRemove && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-1.5 bg-[#23232a] hover:bg-[#2d2d30] rounded-md"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Trash2 className="w-3 h-3 text-red-500"/>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
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">
|
||||
<div
|
||||
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
|
||||
onClick={() => onOpenShortcut(shortcut)}
|
||||
>
|
||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0"/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-white break-words leading-tight">
|
||||
{shortcut.path}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-1.5 bg-[#23232a] hover:bg-[#2d2d30] rounded-md"
|
||||
onClick={() => onRemoveShortcut(shortcut)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3 text-red-500"/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
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]">
|
||||
<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
|
||||
Shortcuts</TabsTrigger>
|
||||
</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">
|
||||
{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>
|
||||
</div>
|
||||
) : recent.map((file) =>
|
||||
renderFileCard(
|
||||
file,
|
||||
() => onRemoveRecent(file),
|
||||
() => file.isPinned ? onUnpinFile(file) : onPinFile(file),
|
||||
file.isPinned
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</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">
|
||||
{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>
|
||||
</div>
|
||||
) : pinned.map((file) =>
|
||||
renderFileCard(
|
||||
file,
|
||||
undefined,
|
||||
() => onUnpinFile(file),
|
||||
true
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</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">
|
||||
<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"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && newShortcut.trim()) {
|
||||
onAddShortcut(newShortcut.trim());
|
||||
setNewShortcut('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 px-2 bg-[#23232a] border-[#434345] hover:bg-[#2d2d30] rounded-md"
|
||||
onClick={() => {
|
||||
if (newShortcut.trim()) {
|
||||
onAddShortcut(newShortcut.trim());
|
||||
setNewShortcut('');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 mr-1"/>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{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>
|
||||
</div>
|
||||
) : shortcuts.map((shortcut) =>
|
||||
renderShortcutCard(shortcut)
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,93 +0,0 @@
|
||||
import React, {useState} from "react";
|
||||
import {SSHManagerSidebar} from "@/apps/SSH/Manager/SSHManagerSidebar.tsx";
|
||||
import {SSHManagerHostViewer} from "@/apps/SSH/Manager/SSHManagerHostViewer.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";
|
||||
|
||||
interface ConfigEditorProps {
|
||||
onSelectView: (view: string) => void;
|
||||
}
|
||||
|
||||
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 function SSHManager({onSelectView}: ConfigEditorProps): React.ReactElement {
|
||||
const [activeTab, setActiveTab] = useState("host_viewer");
|
||||
const [editingHost, setEditingHost] = useState<SSHHost | null>(null);
|
||||
|
||||
const handleEditHost = (host: SSHHost) => {
|
||||
setEditingHost(host);
|
||||
setActiveTab("add_host");
|
||||
};
|
||||
|
||||
const handleFormSubmit = () => {
|
||||
setEditingHost(null);
|
||||
setActiveTab("host_viewer");
|
||||
};
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value);
|
||||
if (value === "host_viewer") {
|
||||
setEditingHost(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SSHManagerSidebar
|
||||
onSelectView={onSelectView}
|
||||
/>
|
||||
|
||||
<div className="flex w-screen h-screen overflow-hidden">
|
||||
<div className="w-[256px]"/>
|
||||
|
||||
<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">
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange}
|
||||
className="flex-1 flex flex-col h-full min-h-0">
|
||||
<TabsList>
|
||||
<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}/>
|
||||
</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"/>
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<SSHManagerHostEditor
|
||||
editingHost={editingHost}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
import React, {useState, useEffect, useMemo} from "react";
|
||||
import {Card, CardContent} from "@/components/ui/card";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {Badge} from "@/components/ui/badge";
|
||||
import {ScrollArea} from "@/components/ui/scroll-area";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion";
|
||||
import {getSSHHosts, deleteSSHHost} from "@/apps/SSH/ssh-axios";
|
||||
import {Edit, Trash2, Server, Folder, Tag, Pin, Terminal, Network, FileEdit, Search} from "lucide-react";
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder: string;
|
||||
tags: string[];
|
||||
pin: boolean;
|
||||
authType: string;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableConfigEditor: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: any[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface SSHManagerHostViewerProps {
|
||||
onEditHost?: (host: SSHHost) => void;
|
||||
}
|
||||
|
||||
export function SSHManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
fetchHosts();
|
||||
}, []);
|
||||
|
||||
const fetchHosts = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await getSSHHosts();
|
||||
setHosts(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Failed to load hosts');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (hostId: number, hostName: string) => {
|
||||
if (window.confirm(`Are you sure you want to delete "${hostName}"?`)) {
|
||||
try {
|
||||
await deleteSSHHost(hostId);
|
||||
await fetchHosts();
|
||||
} catch (err) {
|
||||
alert('Failed to delete host');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (host: SSHHost) => {
|
||||
if (onEditHost) {
|
||||
onEditHost(host);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredAndSortedHosts = useMemo(() => {
|
||||
let filtered = hosts;
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = hosts.filter(host => {
|
||||
const searchableText = [
|
||||
host.name || '',
|
||||
host.username,
|
||||
host.ip,
|
||||
host.folder || '',
|
||||
...(host.tags || []),
|
||||
host.authType,
|
||||
host.defaultPath || ''
|
||||
].join(' ').toLowerCase();
|
||||
return searchableText.includes(query);
|
||||
});
|
||||
}
|
||||
|
||||
return filtered.sort((a, b) => {
|
||||
if (a.pin && !b.pin) return -1;
|
||||
if (!a.pin && b.pin) return 1;
|
||||
|
||||
const aName = a.name || a.username;
|
||||
const bName = b.name || b.username;
|
||||
return aName.localeCompare(bName);
|
||||
});
|
||||
}, [hosts, searchQuery]);
|
||||
|
||||
const hostsByFolder = useMemo(() => {
|
||||
const grouped: { [key: string]: SSHHost[] } = {};
|
||||
|
||||
filteredAndSortedHosts.forEach(host => {
|
||||
const folder = host.folder || 'Uncategorized';
|
||||
if (!grouped[folder]) {
|
||||
grouped[folder] = [];
|
||||
}
|
||||
grouped[folder].push(host);
|
||||
});
|
||||
|
||||
const sortedFolders = Object.keys(grouped).sort((a, b) => {
|
||||
if (a === 'Uncategorized') return -1;
|
||||
if (b === 'Uncategorized') return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
const sortedGrouped: { [key: string]: SSHHost[] } = {};
|
||||
sortedFolders.forEach(folder => {
|
||||
sortedGrouped[folder] = grouped[folder];
|
||||
});
|
||||
|
||||
return sortedGrouped;
|
||||
}, [filteredAndSortedHosts]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||
<p className="text-muted-foreground">Loading hosts...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-4">{error}</p>
|
||||
<Button onClick={fetchHosts} variant="outline">
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hosts.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<Server className="h-12 w-12 text-muted-foreground mx-auto mb-4"/>
|
||||
<h3 className="text-lg font-semibold mb-2">No SSH Hosts</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
You haven't added any SSH hosts yet. Click "Add Host" to get started.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">SSH Hosts</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{filteredAndSortedHosts.length} hosts
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={fetchHosts} variant="outline" size="sm">
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
|
||||
<Input
|
||||
placeholder="Search hosts by name, username, IP, folder, tags..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="space-y-2 pb-20">
|
||||
{Object.entries(hostsByFolder).map(([folder, folderHosts]) => (
|
||||
<div key={folder} className="border rounded-md">
|
||||
<Accordion type="multiple" defaultValue={Object.keys(hostsByFolder)}>
|
||||
<AccordionItem value={folder} className="border-none">
|
||||
<AccordionTrigger
|
||||
className="px-2 py-1 bg-muted/20 border-b hover:no-underline rounded-t-md">
|
||||
<div className="flex items-center gap-2">
|
||||
<Folder className="h-4 w-4"/>
|
||||
<span className="font-medium">{folder}</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{folderHosts.length}
|
||||
</Badge>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="p-2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{folderHosts.map((host) => (
|
||||
<div
|
||||
key={host.id}
|
||||
className="bg-[#222225] border border-input rounded cursor-pointer hover:shadow-md transition-shadow p-2"
|
||||
onClick={() => handleEdit(host)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
{host.pin && <Pin
|
||||
className="h-3 w-3 text-yellow-500 flex-shrink-0"/>}
|
||||
<h3 className="font-medium truncate text-sm">
|
||||
{host.name || `${host.username}@${host.ip}`}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{host.ip}:{host.port}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{host.username}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1 flex-shrink-0 ml-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(host);
|
||||
}}
|
||||
className="h-5 w-5 p-0"
|
||||
>
|
||||
<Edit className="h-3 w-3"/>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(host.id, host.name || `${host.username}@${host.ip}`);
|
||||
}}
|
||||
className="h-5 w-5 p-0 text-red-500 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3 w-3"/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 space-y-1">
|
||||
{host.tags && host.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{host.tags.slice(0, 6).map((tag, index) => (
|
||||
<Badge key={index} variant="secondary"
|
||||
className="text-xs px-1 py-0">
|
||||
<Tag className="h-2 w-2 mr-0.5"/>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{host.tags.length > 6 && (
|
||||
<Badge variant="outline"
|
||||
className="text-xs px-1 py-0">
|
||||
+{host.tags.length - 6}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{host.enableTerminal && (
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
<Terminal className="h-2 w-2 mr-0.5"/>
|
||||
Terminal
|
||||
</Badge>
|
||||
)}
|
||||
{host.enableTunnel && (
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
<Network className="h-2 w-2 mr-0.5"/>
|
||||
Tunnel
|
||||
{host.tunnelConnections && host.tunnelConnections.length > 0 && (
|
||||
<span
|
||||
className="ml-0.5">({host.tunnelConnections.length})</span>
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
{host.enableConfigEditor && (
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
<FileEdit className="h-2 w-2 mr-0.5"/>
|
||||
Config
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
CornerDownLeft
|
||||
} from "lucide-react"
|
||||
|
||||
import {
|
||||
Button
|
||||
} from "@/components/ui/button.tsx"
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuItem, SidebarProvider,
|
||||
} from "@/components/ui/sidebar.tsx"
|
||||
|
||||
import {
|
||||
Separator,
|
||||
} from "@/components/ui/separator.tsx"
|
||||
|
||||
interface SidebarProps {
|
||||
onSelectView: (view: string) => void;
|
||||
}
|
||||
|
||||
export function SSHManagerSidebar({onSelectView}: SidebarProps): React.ReactElement {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<Sidebar>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
|
||||
Termix / SSH Manager
|
||||
</SidebarGroupLabel>
|
||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
||||
<SidebarGroupContent className="flex flex-col flex-grow">
|
||||
<SidebarMenu>
|
||||
|
||||
{/* Sidebar Items */}
|
||||
<SidebarMenuItem key={"Homepage"}>
|
||||
<Button className="w-full mt-2 mb-2 h-8" onClick={() => onSelectView("homepage")}
|
||||
variant="outline">
|
||||
<CornerDownLeft/>
|
||||
Return
|
||||
</Button>
|
||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
||||
</SidebarMenuItem>
|
||||
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,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,329 +0,0 @@
|
||||
import {useEffect, useRef, useState, useImperativeHandle, forwardRef} from 'react';
|
||||
import {useXTerm} from 'react-xtermjs';
|
||||
import {FitAddon} from '@xterm/addon-fit';
|
||||
import {ClipboardAddon} from '@xterm/addon-clipboard';
|
||||
import {Unicode11Addon} from '@xterm/addon-unicode11';
|
||||
import {WebLinksAddon} from '@xterm/addon-web-links';
|
||||
|
||||
interface SSHTerminalProps {
|
||||
hostConfig: any;
|
||||
isVisible: boolean;
|
||||
title?: string;
|
||||
showTitle?: boolean;
|
||||
splitScreen?: boolean;
|
||||
}
|
||||
|
||||
export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
{hostConfig, isVisible, splitScreen = false},
|
||||
ref
|
||||
) {
|
||||
const {instance: terminal, ref: xtermRef} = useXTerm();
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const webSocketRef = useRef<WebSocket | null>(null);
|
||||
const resizeTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
const wasDisconnectedBySSH = useRef(false);
|
||||
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
disconnect: () => {
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
webSocketRef.current?.close();
|
||||
},
|
||||
fit: () => {
|
||||
fitAddonRef.current?.fit();
|
||||
},
|
||||
sendInput: (data: string) => {
|
||||
if (webSocketRef.current?.readyState === 1) {
|
||||
webSocketRef.current.send(JSON.stringify({type: 'input', data}));
|
||||
}
|
||||
}
|
||||
}), []);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', handleWindowResize);
|
||||
return () => window.removeEventListener('resize', handleWindowResize);
|
||||
}, []);
|
||||
|
||||
function handleWindowResize() {
|
||||
fitAddonRef.current?.fit();
|
||||
}
|
||||
|
||||
function getCookie(name: string) {
|
||||
return document.cookie.split('; ').reduce((r, v) => {
|
||||
const parts = v.split('=');
|
||||
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
|
||||
}, "");
|
||||
}
|
||||
|
||||
function getUseRightClickCopyPaste() {
|
||||
return getCookie("rightClickCopyPaste") === "true"
|
||||
}
|
||||
|
||||
async function writeTextToClipboard(text: string): Promise<void> {
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
} catch (_) {
|
||||
}
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.left = '-9999px';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
async function readTextFromClipboard(): Promise<string> {
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.readText) {
|
||||
return await navigator.clipboard.readText();
|
||||
}
|
||||
} catch (_) {
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminal || !xtermRef.current || !hostConfig) return;
|
||||
|
||||
terminal.options = {
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'bar',
|
||||
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',
|
||||
},
|
||||
allowTransparency: true,
|
||||
convertEol: true,
|
||||
windowsMode: false,
|
||||
macOptionIsMeta: false,
|
||||
macOptionClickForcesSelection: false,
|
||||
rightClickSelectsWord: false,
|
||||
fastScrollModifier: 'alt',
|
||||
fastScrollSensitivity: 5,
|
||||
allowProposedApi: true,
|
||||
};
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
const clipboardAddon = new ClipboardAddon();
|
||||
const unicode11Addon = new Unicode11Addon();
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
|
||||
fitAddonRef.current = fitAddon;
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.loadAddon(clipboardAddon);
|
||||
terminal.loadAddon(unicode11Addon);
|
||||
terminal.loadAddon(webLinksAddon);
|
||||
terminal.open(xtermRef.current);
|
||||
|
||||
const element = xtermRef.current;
|
||||
const handleContextMenu = async (e: MouseEvent) => {
|
||||
if (!getUseRightClickCopyPaste()) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
if (terminal.hasSelection()) {
|
||||
const selection = terminal.getSelection();
|
||||
if (selection) {
|
||||
await writeTextToClipboard(selection);
|
||||
terminal.clearSelection();
|
||||
}
|
||||
} else {
|
||||
const pasteText = await readTextFromClipboard();
|
||||
if (pasteText) {
|
||||
terminal.paste(pasteText);
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
}
|
||||
};
|
||||
if (element) {
|
||||
element.addEventListener('contextmenu', handleContextMenu);
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||
resizeTimeout.current = setTimeout(() => {
|
||||
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}}));
|
||||
}
|
||||
}, 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'}));
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
ws.addEventListener('message', (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'data') {
|
||||
terminal.write(msg.data);
|
||||
} else if (msg.type === 'error') {
|
||||
terminal.writeln(`\r\n[ERROR] ${msg.message}`);
|
||||
} else if (msg.type === 'connected') {
|
||||
} else if (msg.type === 'disconnected') {
|
||||
wasDisconnectedBySSH.current = true;
|
||||
terminal.writeln(`\r\n[${msg.message || 'Disconnected'}]`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing WebSocket message:', error);
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
if (!wasDisconnectedBySSH.current) {
|
||||
terminal.writeln('\r\n[Connection closed]');
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener('error', () => {
|
||||
terminal.writeln('\r\n[Connection error]');
|
||||
});
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
if (element) {
|
||||
element.removeEventListener('contextmenu', handleContextMenu);
|
||||
}
|
||||
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
webSocketRef.current?.close();
|
||||
};
|
||||
}, [xtermRef, terminal, hostConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible && fitAddonRef.current) {
|
||||
fitAddonRef.current.fit();
|
||||
}
|
||||
}, [isVisible]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={xtermRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
marginLeft: 2,
|
||||
opacity: visible && isVisible ? 1 : 0,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.innerHTML = `
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&display=swap');
|
||||
|
||||
/* Load NerdFonts locally */
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono Nerd Font';
|
||||
src: url('/fonts/JetBrainsMonoNerdFont-Regular.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono Nerd Font';
|
||||
src: url('/fonts/JetBrainsMonoNerdFont-Bold.ttf') format('truetype');
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono Nerd Font';
|
||||
src: url('/fonts/JetBrainsMonoNerdFont-Italic.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
background: transparent;
|
||||
}
|
||||
.xterm .xterm-viewport::-webkit-scrollbar-thumb {
|
||||
background: rgba(180,180,180,0.7);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.xterm .xterm-viewport::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(120,120,120,0.9);
|
||||
}
|
||||
.xterm .xterm-viewport {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(180,180,180,0.7) transparent;
|
||||
}
|
||||
|
||||
.xterm {
|
||||
font-feature-settings: "liga" 1, "calt" 1;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font', 'Cascadia Code', 'JetBrains Mono', Consolas, "Courier New", monospace !important;
|
||||
font-variant-ligatures: contextual;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen .xterm-char {
|
||||
font-feature-settings: "liga" 1, "calt" 1;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen .xterm-char[data-char-code^="\\uE"] {
|
||||
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
@@ -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,160 +0,0 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: 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;
|
||||
}
|
||||
|
||||
export function SSHTunnel({onSelectView}: ConfigEditorProps): React.ReactElement {
|
||||
const [hosts, setHosts] = 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 fetchTunnelStatuses = useCallback(async () => {
|
||||
try {
|
||||
const statusData = await getTunnelStatuses();
|
||||
setTunnelStatuses(statusData);
|
||||
} catch (err) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchHosts();
|
||||
const interval = setInterval(fetchHosts, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchHosts]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTunnelStatuses();
|
||||
const interval = setInterval(fetchTunnelStatuses, 500);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchTunnelStatuses]);
|
||||
|
||||
const handleTunnelAction = async (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => {
|
||||
const tunnel = host.tunnelConnections[tunnelIndex];
|
||||
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
|
||||
|
||||
setTunnelActions(prev => ({...prev, [tunnelName]: true}));
|
||||
|
||||
try {
|
||||
if (action === 'connect') {
|
||||
const endpointHost = hosts.find(h =>
|
||||
h.name === tunnel.endpointHost ||
|
||||
`${h.username}@${h.ip}` === tunnel.endpointHost
|
||||
);
|
||||
|
||||
if (!endpointHost) {
|
||||
throw new Error('Endpoint host not found');
|
||||
}
|
||||
|
||||
const tunnelConfig = {
|
||||
name: tunnelName,
|
||||
hostName: host.name || `${host.username}@${host.ip}`,
|
||||
sourceIP: host.ip,
|
||||
sourceSSHPort: host.port,
|
||||
sourceUsername: host.username,
|
||||
sourcePassword: host.authType === 'password' ? host.password : undefined,
|
||||
sourceAuthMethod: host.authType,
|
||||
sourceSSHKey: host.authType === 'key' ? host.key : undefined,
|
||||
sourceKeyPassword: host.authType === 'key' ? host.keyPassword : undefined,
|
||||
sourceKeyType: host.authType === 'key' ? host.keyType : undefined,
|
||||
endpointIP: endpointHost.ip,
|
||||
endpointSSHPort: endpointHost.port,
|
||||
endpointUsername: endpointHost.username,
|
||||
endpointPassword: endpointHost.authType === 'password' ? endpointHost.password : undefined,
|
||||
endpointAuthMethod: endpointHost.authType,
|
||||
endpointSSHKey: endpointHost.authType === 'key' ? endpointHost.key : undefined,
|
||||
endpointKeyPassword: endpointHost.authType === 'key' ? endpointHost.keyPassword : undefined,
|
||||
endpointKeyType: endpointHost.authType === 'key' ? endpointHost.keyType : undefined,
|
||||
sourcePort: tunnel.sourcePort,
|
||||
endpointPort: tunnel.endpointPort,
|
||||
maxRetries: tunnel.maxRetries,
|
||||
retryInterval: tunnel.retryInterval * 1000,
|
||||
autoStart: tunnel.autoStart,
|
||||
isPinned: host.pin
|
||||
};
|
||||
|
||||
await connectTunnel(tunnelConfig);
|
||||
} else if (action === 'disconnect') {
|
||||
await disconnectTunnel(tunnelName);
|
||||
} else if (action === 'cancel') {
|
||||
await cancelTunnel(tunnelName);
|
||||
}
|
||||
|
||||
await fetchTunnelStatuses();
|
||||
} catch (err) {
|
||||
} finally {
|
||||
setTunnelActions(prev => ({...prev, [tunnelName]: false}));
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,353 +0,0 @@
|
||||
import React from "react";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card.tsx";
|
||||
import {Separator} from "@/components/ui/separator.tsx";
|
||||
import {
|
||||
Loader2,
|
||||
Pin,
|
||||
Terminal,
|
||||
Network,
|
||||
FileEdit,
|
||||
Tag,
|
||||
Play,
|
||||
Square,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Zap,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import {Badge} from "@/components/ui/badge.tsx";
|
||||
|
||||
const CONNECTION_STATES = {
|
||||
DISCONNECTED: "disconnected",
|
||||
CONNECTING: "connecting",
|
||||
CONNECTED: "connected",
|
||||
VERIFYING: "verifying",
|
||||
FAILED: "failed",
|
||||
UNSTABLE: "unstable",
|
||||
RETRYING: "retrying",
|
||||
WAITING: "waiting",
|
||||
DISCONNECTING: "disconnecting"
|
||||
};
|
||||
|
||||
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 SSHTunnelObjectProps {
|
||||
host: SSHHost;
|
||||
tunnelStatuses: Record<string, TunnelStatus>;
|
||||
tunnelActions: Record<string, boolean>;
|
||||
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>;
|
||||
}
|
||||
|
||||
export function SSHTunnelObject({
|
||||
host,
|
||||
tunnelStatuses,
|
||||
tunnelActions,
|
||||
onTunnelAction
|
||||
}: SSHTunnelObjectProps): React.ReactElement {
|
||||
|
||||
const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => {
|
||||
const tunnel = host.tunnelConnections[tunnelIndex];
|
||||
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
|
||||
return tunnelStatuses[tunnelName];
|
||||
};
|
||||
|
||||
const getTunnelStatusDisplay = (status: TunnelStatus | undefined) => {
|
||||
if (!status) return {
|
||||
icon: <WifiOff className="h-4 w-4"/>,
|
||||
text: 'Unknown',
|
||||
color: 'text-muted-foreground',
|
||||
bgColor: 'bg-muted/50',
|
||||
borderColor: 'border-border'
|
||||
};
|
||||
|
||||
const statusValue = status.status || 'DISCONNECTED';
|
||||
|
||||
switch (statusValue.toUpperCase()) {
|
||||
case 'CONNECTED':
|
||||
return {
|
||||
icon: <Wifi className="h-4 w-4"/>,
|
||||
text: 'Connected',
|
||||
color: 'text-green-600 dark:text-green-400',
|
||||
bgColor: 'bg-green-500/10 dark:bg-green-400/10',
|
||||
borderColor: 'border-green-500/20 dark:border-green-400/20'
|
||||
};
|
||||
case 'CONNECTING':
|
||||
return {
|
||||
icon: <Loader2 className="h-4 w-4 animate-spin"/>,
|
||||
text: 'Connecting...',
|
||||
color: 'text-blue-600 dark:text-blue-400',
|
||||
bgColor: 'bg-blue-500/10 dark:bg-blue-400/10',
|
||||
borderColor: 'border-blue-500/20 dark:border-blue-400/20'
|
||||
};
|
||||
case 'DISCONNECTING':
|
||||
return {
|
||||
icon: <Loader2 className="h-4 w-4 animate-spin"/>,
|
||||
text: 'Disconnecting...',
|
||||
color: 'text-orange-600 dark:text-orange-400',
|
||||
bgColor: 'bg-orange-500/10 dark:bg-orange-400/10',
|
||||
borderColor: 'border-orange-500/20 dark:border-orange-400/20'
|
||||
};
|
||||
case 'DISCONNECTED':
|
||||
return {
|
||||
icon: <WifiOff className="h-4 w-4"/>,
|
||||
text: 'Disconnected',
|
||||
color: 'text-muted-foreground',
|
||||
bgColor: 'bg-muted/30',
|
||||
borderColor: 'border-border'
|
||||
};
|
||||
case 'WAITING':
|
||||
return {
|
||||
icon: <Clock className="h-4 w-4"/>,
|
||||
color: 'text-blue-600 dark:text-blue-400',
|
||||
bgColor: 'bg-blue-500/10 dark:bg-blue-400/10',
|
||||
borderColor: 'border-blue-500/20 dark:border-blue-400/20'
|
||||
};
|
||||
case 'ERROR':
|
||||
case 'FAILED':
|
||||
return {
|
||||
icon: <AlertCircle className="h-4 w-4"/>,
|
||||
text: status.reason || 'Error',
|
||||
color: 'text-red-600 dark:text-red-400',
|
||||
bgColor: 'bg-red-500/10 dark:bg-red-400/10',
|
||||
borderColor: 'border-red-500/20 dark:border-red-400/20'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: <WifiOff className="h-4 w-4"/>,
|
||||
text: statusValue,
|
||||
color: 'text-muted-foreground',
|
||||
bgColor: 'bg-muted/30',
|
||||
borderColor: 'border-border'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{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">
|
||||
<Tag className="h-2 w-2 mr-0.5"/>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{host.tags.length > 3 && (
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
+{host.tags.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
{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 ${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`}>
|
||||
{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 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 ? (
|
||||
<>
|
||||
<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>
|
||||
)}
|
||||
{isActionLoading && (
|
||||
<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>
|
||||
|
||||
{/* 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">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<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>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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, () => {
|
||||
});
|
||||
@@ -1,62 +1,143 @@
|
||||
import {drizzle} from 'drizzle-orm/better-sqlite3';
|
||||
import Database from 'better-sqlite3';
|
||||
import * as schema from './schema.js';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import Database from "better-sqlite3";
|
||||
import * as schema from "./schema.js";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import { DatabaseFileEncryption } from "../../utils/database-file-encryption.js";
|
||||
import { SystemCrypto } from "../../utils/system-crypto.js";
|
||||
import { DatabaseMigration } from "../../utils/database-migration.js";
|
||||
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||
|
||||
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('#1e3a8a')(`[${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));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const dataDir = process.env.DATA_DIR || './db/data';
|
||||
const dataDir = process.env.DATA_DIR || "./db/data";
|
||||
const dbDir = path.resolve(dataDir);
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, {recursive: true});
|
||||
databaseLogger.info(`Creating database directory`, {
|
||||
operation: "db_init",
|
||||
path: dbDir,
|
||||
});
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
const dbPath = path.join(dataDir, 'db.sqlite');
|
||||
const sqlite = new Database(dbPath);
|
||||
const enableFileEncryption = process.env.DB_FILE_ENCRYPTION !== "false";
|
||||
const dbPath = path.join(dataDir, "db.sqlite");
|
||||
const encryptedDbPath = `${dbPath}.encrypted`;
|
||||
|
||||
sqlite.exec(`
|
||||
let actualDbPath = ":memory:";
|
||||
let memoryDatabase: Database.Database;
|
||||
let isNewDatabase = false;
|
||||
let sqlite: Database.Database;
|
||||
|
||||
async function initializeDatabaseAsync(): Promise<void> {
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
|
||||
const dbKey = await systemCrypto.getDatabaseKey();
|
||||
if (enableFileEncryption) {
|
||||
try {
|
||||
if (DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath)) {
|
||||
const decryptedBuffer =
|
||||
await DatabaseFileEncryption.decryptDatabaseToBuffer(encryptedDbPath);
|
||||
|
||||
memoryDatabase = new Database(decryptedBuffer);
|
||||
} else {
|
||||
const migration = new DatabaseMigration(dataDir);
|
||||
const migrationStatus = migration.checkMigrationStatus();
|
||||
|
||||
if (migrationStatus.needsMigration) {
|
||||
const migrationResult = await migration.migrateDatabase();
|
||||
|
||||
if (migrationResult.success) {
|
||||
migration.cleanupOldBackups();
|
||||
|
||||
if (
|
||||
DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath)
|
||||
) {
|
||||
const decryptedBuffer =
|
||||
await DatabaseFileEncryption.decryptDatabaseToBuffer(
|
||||
encryptedDbPath,
|
||||
);
|
||||
memoryDatabase = new Database(decryptedBuffer);
|
||||
isNewDatabase = false;
|
||||
} else {
|
||||
throw new Error(
|
||||
"Migration completed but encrypted database file not found",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
databaseLogger.error("Automatic database migration failed", null, {
|
||||
operation: "auto_migration_failed",
|
||||
error: migrationResult.error,
|
||||
migratedTables: migrationResult.migratedTables,
|
||||
migratedRows: migrationResult.migratedRows,
|
||||
duration: migrationResult.duration,
|
||||
backupPath: migrationResult.backupPath,
|
||||
});
|
||||
throw new Error(
|
||||
`Database migration failed: ${migrationResult.error}. Backup available at: ${migrationResult.backupPath}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
memoryDatabase = new Database(":memory:");
|
||||
isNewDatabase = true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to initialize memory database", error, {
|
||||
operation: "db_memory_init_failed",
|
||||
errorMessage: error instanceof Error ? error.message : "Unknown error",
|
||||
errorStack: error instanceof Error ? error.stack : undefined,
|
||||
encryptedDbExists:
|
||||
DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath),
|
||||
databaseKeyAvailable: !!process.env.DATABASE_KEY,
|
||||
databaseKeyLength: process.env.DATABASE_KEY?.length || 0,
|
||||
});
|
||||
|
||||
throw new Error(
|
||||
`Database decryption failed: ${error instanceof Error ? error.message : "Unknown error"}. This prevents data loss.`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
memoryDatabase = new Database(":memory:");
|
||||
isNewDatabase = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeCompleteDatabase(): Promise<void> {
|
||||
await initializeDatabaseAsync();
|
||||
|
||||
databaseLogger.info(`Initializing SQLite database`, {
|
||||
operation: "db_init",
|
||||
path: actualDbPath,
|
||||
encrypted:
|
||||
enableFileEncryption &&
|
||||
DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath),
|
||||
inMemory: true,
|
||||
isNewDatabase,
|
||||
});
|
||||
|
||||
sqlite = memoryDatabase;
|
||||
|
||||
db = drizzle(sqlite, { schema });
|
||||
|
||||
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
|
||||
oidc_identifier TEXT,
|
||||
client_id TEXT,
|
||||
client_secret TEXT,
|
||||
issuer_url TEXT,
|
||||
authorization_url TEXT,
|
||||
token_url TEXT,
|
||||
identifier_path TEXT,
|
||||
name_path TEXT,
|
||||
scopes TEXT DEFAULT 'openid email profile',
|
||||
totp_secret TEXT,
|
||||
totp_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
totp_backup_codes TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
@@ -82,116 +163,382 @@ sqlite.exec(`
|
||||
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,
|
||||
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)
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config_editor_recent (
|
||||
CREATE TABLE IF NOT EXISTS file_manager_recent (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
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)
|
||||
FOREIGN KEY (user_id) REFERENCES users (id),
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config_editor_pinned (
|
||||
CREATE TABLE IF NOT EXISTS file_manager_pinned (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
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)
|
||||
FOREIGN KEY (user_id) REFERENCES users (id),
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config_editor_shortcuts (
|
||||
CREATE TABLE IF NOT EXISTS file_manager_shortcuts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
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)
|
||||
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)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
folder TEXT,
|
||||
tags TEXT,
|
||||
auth_type TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
password TEXT,
|
||||
key TEXT,
|
||||
key_password TEXT,
|
||||
key_type TEXT,
|
||||
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_used 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_credential_usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
credential_id INTEGER NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id),
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id),
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
);
|
||||
|
||||
`);
|
||||
|
||||
const addColumnIfNotExists = (table: string, column: string, definition: string) => {
|
||||
try {
|
||||
sqlite.prepare(`SELECT ${column}
|
||||
FROM ${table} LIMIT 1`).get();
|
||||
} catch (e) {
|
||||
try {
|
||||
sqlite.exec(`ALTER TABLE ${table}
|
||||
ADD COLUMN ${column} ${definition};`);
|
||||
} catch (alterError) {
|
||||
logger.warn(`Failed to add column ${column} to ${table}: ${alterError}`);
|
||||
}
|
||||
migrateSchema();
|
||||
|
||||
try {
|
||||
const row = sqlite
|
||||
.prepare("SELECT value FROM settings WHERE key = 'allow_registration'")
|
||||
.get();
|
||||
if (!row) {
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')",
|
||||
)
|
||||
.run();
|
||||
}
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not initialize default settings", {
|
||||
operation: "db_init",
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const addColumnIfNotExists = (
|
||||
table: string,
|
||||
column: string,
|
||||
definition: string,
|
||||
) => {
|
||||
try {
|
||||
sqlite
|
||||
.prepare(
|
||||
`SELECT ${column}
|
||||
FROM ${table} LIMIT 1`,
|
||||
)
|
||||
.get();
|
||||
} catch (e) {
|
||||
try {
|
||||
sqlite.exec(`ALTER TABLE ${table}
|
||||
ADD COLUMN ${column} ${definition};`);
|
||||
} catch (alterError) {
|
||||
databaseLogger.warn(`Failed to add column ${column} to ${table}`, {
|
||||
operation: "schema_migration",
|
||||
table,
|
||||
column,
|
||||
error: alterError,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const migrateSchema = () => {
|
||||
logger.info('Checking for schema updates...');
|
||||
addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
|
||||
|
||||
addColumnIfNotExists('users', 'is_admin', 'INTEGER NOT NULL DEFAULT 0');
|
||||
addColumnIfNotExists("users", "is_oidc", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumnIfNotExists("users", "oidc_identifier", "TEXT");
|
||||
addColumnIfNotExists("users", "client_id", "TEXT");
|
||||
addColumnIfNotExists("users", "client_secret", "TEXT");
|
||||
addColumnIfNotExists("users", "issuer_url", "TEXT");
|
||||
addColumnIfNotExists("users", "authorization_url", "TEXT");
|
||||
addColumnIfNotExists("users", "token_url", "TEXT");
|
||||
|
||||
addColumnIfNotExists('users', 'is_oidc', 'INTEGER NOT NULL DEFAULT 0');
|
||||
addColumnIfNotExists('users', 'oidc_identifier', 'TEXT');
|
||||
addColumnIfNotExists('users', 'client_id', 'TEXT');
|
||||
addColumnIfNotExists('users', 'client_secret', 'TEXT');
|
||||
addColumnIfNotExists('users', 'issuer_url', 'TEXT');
|
||||
addColumnIfNotExists('users', 'authorization_url', 'TEXT');
|
||||
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');
|
||||
addColumnIfNotExists("users", "identifier_path", "TEXT");
|
||||
addColumnIfNotExists("users", "name_path", "TEXT");
|
||||
addColumnIfNotExists("users", "scopes", "TEXT");
|
||||
|
||||
addColumnIfNotExists('ssh_data', 'name', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'folder', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'tags', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'pin', 'INTEGER NOT NULL DEFAULT 0');
|
||||
addColumnIfNotExists('ssh_data', 'auth_type', 'TEXT NOT NULL DEFAULT "password"');
|
||||
addColumnIfNotExists('ssh_data', 'password', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'key', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'key_password', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'key_type', 'TEXT');
|
||||
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', '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("users", "totp_secret", "TEXT");
|
||||
addColumnIfNotExists("users", "totp_enabled", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumnIfNotExists("users", "totp_backup_codes", "TEXT");
|
||||
|
||||
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("ssh_data", "name", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "folder", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "tags", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "pin", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"auth_type",
|
||||
'TEXT NOT NULL DEFAULT "password"',
|
||||
);
|
||||
addColumnIfNotExists("ssh_data", "password", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "key", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "key_password", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "key_type", "TEXT");
|
||||
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_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",
|
||||
);
|
||||
|
||||
logger.success('Schema migration completed');
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"credential_id",
|
||||
"INTEGER REFERENCES ssh_credentials(id)",
|
||||
);
|
||||
|
||||
addColumnIfNotExists("ssh_data", "autostart_password", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "autostart_key", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "autostart_key_password", "TEXT");
|
||||
|
||||
addColumnIfNotExists("ssh_credentials", "private_key", "TEXT");
|
||||
addColumnIfNotExists("ssh_credentials", "public_key", "TEXT");
|
||||
addColumnIfNotExists("ssh_credentials", "detected_key_type", "TEXT");
|
||||
|
||||
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");
|
||||
|
||||
databaseLogger.success("Schema migration completed", {
|
||||
operation: "schema_migration",
|
||||
});
|
||||
};
|
||||
|
||||
migrateSchema();
|
||||
async function saveMemoryDatabaseToFile() {
|
||||
if (!memoryDatabase) return;
|
||||
|
||||
try {
|
||||
const row = sqlite.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get();
|
||||
if (!row) {
|
||||
sqlite.prepare("INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')").run();
|
||||
try {
|
||||
const buffer = memoryDatabase.serialize();
|
||||
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('Could not initialize default settings');
|
||||
|
||||
if (enableFileEncryption) {
|
||||
await DatabaseFileEncryption.encryptDatabaseFromBuffer(
|
||||
buffer,
|
||||
encryptedDbPath,
|
||||
);
|
||||
} else {
|
||||
fs.writeFileSync(dbPath, buffer);
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to save in-memory database", error, {
|
||||
operation: "memory_db_save_failed",
|
||||
enableFileEncryption,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const db = drizzle(sqlite, {schema});
|
||||
async function handlePostInitFileEncryption() {
|
||||
if (!enableFileEncryption) return;
|
||||
|
||||
try {
|
||||
if (memoryDatabase) {
|
||||
await saveMemoryDatabaseToFile();
|
||||
|
||||
setInterval(saveMemoryDatabaseToFile, 15 * 1000);
|
||||
|
||||
DatabaseSaveTrigger.initialize(saveMemoryDatabaseToFile);
|
||||
}
|
||||
|
||||
try {
|
||||
const migration = new DatabaseMigration(dataDir);
|
||||
migration.cleanupOldBackups();
|
||||
} catch (cleanupError) {
|
||||
databaseLogger.warn("Failed to cleanup old migration files", {
|
||||
operation: "migration_cleanup_startup_failed",
|
||||
error:
|
||||
cleanupError instanceof Error
|
||||
? cleanupError.message
|
||||
: "Unknown error",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Failed to handle database file encryption setup",
|
||||
error,
|
||||
{
|
||||
operation: "db_encrypt_setup_failed",
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeDatabase(): Promise<void> {
|
||||
await initializeCompleteDatabase();
|
||||
await handlePostInitFileEncryption();
|
||||
}
|
||||
|
||||
export { initializeDatabase };
|
||||
|
||||
async function cleanupDatabase() {
|
||||
if (memoryDatabase) {
|
||||
try {
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Failed to save in-memory database before shutdown",
|
||||
error,
|
||||
{
|
||||
operation: "shutdown_save_failed",
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (sqlite) {
|
||||
sqlite.close();
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Error closing database connection", {
|
||||
operation: "db_close_error",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const tempDir = path.join(dataDir, ".temp");
|
||||
if (fs.existsSync(tempDir)) {
|
||||
const files = fs.readdirSync(tempDir);
|
||||
for (const file of files) {
|
||||
try {
|
||||
fs.unlinkSync(path.join(tempDir, file));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
try {
|
||||
fs.rmdirSync(tempDir);
|
||||
} catch {}
|
||||
}
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
process.on("exit", () => {
|
||||
if (sqlite) {
|
||||
try {
|
||||
sqlite.close();
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
|
||||
process.on("SIGINT", async () => {
|
||||
databaseLogger.info("Received SIGINT, cleaning up...", {
|
||||
operation: "shutdown",
|
||||
});
|
||||
await cleanupDatabase();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
databaseLogger.info("Received SIGTERM, cleaning up...", {
|
||||
operation: "shutdown",
|
||||
});
|
||||
await cleanupDatabase();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
let db: ReturnType<typeof drizzle<typeof schema>>;
|
||||
|
||||
export function getDb(): ReturnType<typeof drizzle<typeof schema>> {
|
||||
if (!db) {
|
||||
throw new Error(
|
||||
"Database not initialized. Ensure initializeDatabase() is called before accessing db.",
|
||||
);
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export function getSqlite(): Database.Database {
|
||||
if (!sqlite) {
|
||||
throw new Error(
|
||||
"SQLite not initialized. Ensure initializeDatabase() is called before accessing sqlite.",
|
||||
);
|
||||
}
|
||||
return sqlite;
|
||||
}
|
||||
|
||||
export { db };
|
||||
export { DatabaseFileEncryption };
|
||||
export const databasePaths = {
|
||||
main: actualDbPath,
|
||||
encrypted: encryptedDbPath,
|
||||
directory: dbDir,
|
||||
inMemory: true,
|
||||
};
|
||||
|
||||
export { saveMemoryDatabaseToFile };
|
||||
|
||||
export { DatabaseSaveTrigger };
|
||||
|
||||
@@ -1,76 +1,174 @@
|
||||
import {sqliteTable, text, integer} from 'drizzle-orm/sqlite-core';
|
||||
import {sql} from 'drizzle-orm';
|
||||
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
export const users = sqliteTable('users', {
|
||||
id: text('id').primaryKey(),
|
||||
username: text('username').notNull(),
|
||||
password_hash: text('password_hash').notNull(),
|
||||
is_admin: integer('is_admin', {mode: 'boolean'}).notNull().default(false),
|
||||
export const users = sqliteTable("users", {
|
||||
id: text("id").primaryKey(),
|
||||
username: text("username").notNull(),
|
||||
password_hash: text("password_hash").notNull(),
|
||||
is_admin: integer("is_admin", { mode: "boolean" }).notNull().default(false),
|
||||
|
||||
is_oidc: integer('is_oidc', {mode: 'boolean'}).notNull().default(false),
|
||||
oidc_identifier: text('oidc_identifier'),
|
||||
client_id: text('client_id'),
|
||||
client_secret: text('client_secret'),
|
||||
issuer_url: text('issuer_url'),
|
||||
authorization_url: text('authorization_url'),
|
||||
token_url: text('token_url'),
|
||||
identifier_path: text('identifier_path'),
|
||||
name_path: text('name_path'),
|
||||
scopes: text().default("openid email profile"),
|
||||
is_oidc: integer("is_oidc", { mode: "boolean" }).notNull().default(false),
|
||||
oidc_identifier: text("oidc_identifier"),
|
||||
client_id: text("client_id"),
|
||||
client_secret: text("client_secret"),
|
||||
issuer_url: text("issuer_url"),
|
||||
authorization_url: text("authorization_url"),
|
||||
token_url: text("token_url"),
|
||||
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', {
|
||||
key: text('key').primaryKey(),
|
||||
value: text('value').notNull(),
|
||||
export const settings = sqliteTable("settings", {
|
||||
key: text("key").primaryKey(),
|
||||
value: text("value").notNull(),
|
||||
});
|
||||
|
||||
export const sshData = sqliteTable('ssh_data', {
|
||||
id: integer('id').primaryKey({autoIncrement: true}),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
name: text('name'),
|
||||
ip: text('ip').notNull(),
|
||||
port: integer('port').notNull(),
|
||||
username: text('username').notNull(),
|
||||
folder: text('folder'),
|
||||
tags: text('tags'),
|
||||
pin: integer('pin', {mode: 'boolean'}).notNull().default(false),
|
||||
authType: text('auth_type').notNull(),
|
||||
password: text('password'),
|
||||
key: text('key', {length: 8192}),
|
||||
keyPassword: text('key_password'),
|
||||
keyType: text('key_type'),
|
||||
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),
|
||||
defaultPath: text('default_path'),
|
||||
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
export const sshData = sqliteTable("ssh_data", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
name: text("name"),
|
||||
ip: text("ip").notNull(),
|
||||
port: integer("port").notNull(),
|
||||
username: text("username").notNull(),
|
||||
folder: text("folder"),
|
||||
tags: text("tags"),
|
||||
pin: integer("pin", { mode: "boolean" }).notNull().default(false),
|
||||
authType: text("auth_type").notNull(),
|
||||
|
||||
password: text("password"),
|
||||
key: text("key", { length: 8192 }),
|
||||
key_password: text("key_password"),
|
||||
keyType: text("key_type"),
|
||||
|
||||
autostartPassword: text("autostart_password"),
|
||||
autostartKey: text("autostart_key", { length: 8192 }),
|
||||
autostartKeyPassword: text("autostart_key_password"),
|
||||
|
||||
credentialId: integer("credential_id").references(() => sshCredentials.id),
|
||||
enableTerminal: integer("enable_terminal", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
enableTunnel: integer("enable_tunnel", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
tunnelConnections: text("tunnel_connections"),
|
||||
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', {
|
||||
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(),
|
||||
lastOpened: text('last_opened').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
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),
|
||||
name: text("name").notNull(),
|
||||
path: text("path").notNull(),
|
||||
lastOpened: text("last_opened")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const configEditorPinned = sqliteTable('config_editor_pinned', {
|
||||
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(),
|
||||
pinnedAt: text('pinned_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
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),
|
||||
name: text("name").notNull(),
|
||||
path: text("path").notNull(),
|
||||
pinnedAt: text("pinned_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const configEditorShortcuts = sqliteTable('config_editor_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 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`),
|
||||
});
|
||||
|
||||
export const sshCredentials = sqliteTable("ssh_credentials", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
folder: text("folder"),
|
||||
tags: text("tags"),
|
||||
authType: text("auth_type").notNull(),
|
||||
username: text("username").notNull(),
|
||||
password: text("password"),
|
||||
key: text("key", { length: 16384 }),
|
||||
private_key: text("private_key", { length: 16384 }),
|
||||
public_key: text("public_key", { length: 4096 }),
|
||||
key_password: text("key_password"),
|
||||
keyType: text("key_type"),
|
||||
detectedKeyType: text("detected_key_type"),
|
||||
usageCount: integer("usage_count").notNull().default(0),
|
||||
lastUsed: text("last_used"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const sshCredentialUsage = sqliteTable("ssh_credential_usage", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
credentialId: integer("credential_id")
|
||||
.notNull()
|
||||
.references(() => sshCredentials.id),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
usedAt: text("used_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
239
src/backend/database/routes/alerts.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import express from "express";
|
||||
import { db } from "../db/index.js";
|
||||
import { dismissedAlerts } from "../db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import fetch from "node-fetch";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
|
||||
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) {
|
||||
authLogger.warn("GitHub API returned error status", {
|
||||
operation: "alerts_fetch",
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
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) {
|
||||
authLogger.error("Failed to fetch alerts from GitHub", {
|
||||
operation: "alerts_fetch",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
// Route: Get alerts for the authenticated user (excluding dismissed ones)
|
||||
// GET /alerts
|
||||
router.get("/", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const userId = (req as any).userId;
|
||||
|
||||
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 activeAlertsForUser = allAlerts.filter(
|
||||
(alert) => !dismissedAlertIds.has(alert.id),
|
||||
);
|
||||
|
||||
res.json({
|
||||
alerts: activeAlertsForUser,
|
||||
cached: alertCache.get("termix_alerts") !== null,
|
||||
total_count: activeAlertsForUser.length,
|
||||
});
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to get user alerts", error);
|
||||
res.status(500).json({ error: "Failed to fetch alerts" });
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Dismiss an alert for the authenticated user
|
||||
// POST /alerts/dismiss
|
||||
router.post("/dismiss", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const { alertId } = req.body;
|
||||
const userId = (req as any).userId;
|
||||
|
||||
if (!alertId) {
|
||||
authLogger.warn("Missing alertId in dismiss request", { userId });
|
||||
return res.status(400).json({ error: "Alert ID is required" });
|
||||
}
|
||||
|
||||
const existingDismissal = await db
|
||||
.select()
|
||||
.from(dismissedAlerts)
|
||||
.where(
|
||||
and(
|
||||
eq(dismissedAlerts.userId, userId),
|
||||
eq(dismissedAlerts.alertId, alertId),
|
||||
),
|
||||
);
|
||||
|
||||
if (existingDismissal.length > 0) {
|
||||
authLogger.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,
|
||||
});
|
||||
|
||||
res.json({ message: "Alert dismissed successfully" });
|
||||
} catch (error) {
|
||||
authLogger.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", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const userId = (req as any).userId;
|
||||
|
||||
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) {
|
||||
authLogger.error("Failed to get dismissed alerts", error);
|
||||
res.status(500).json({ error: "Failed to fetch dismissed alerts" });
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Undismiss an alert for the authenticated user (remove from dismissed list)
|
||||
// DELETE /alerts/dismiss
|
||||
router.delete("/dismiss", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const { alertId } = req.body;
|
||||
const userId = (req as any).userId;
|
||||
|
||||
if (!alertId) {
|
||||
return res.status(400).json({ error: "Alert ID is 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" });
|
||||
}
|
||||
res.json({ message: "Alert undismissed successfully" });
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to undismiss alert", error);
|
||||
res.status(500).json({ error: "Failed to undismiss alert" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
1609
src/backend/database/routes/credentials.ts
Normal file
2119
src/backend/ssh/file-manager.ts
Normal file
1026
src/backend/ssh/server-stats.ts
Normal file
@@ -1,363 +0,0 @@
|
||||
import {WebSocketServer, WebSocket, type RawData} from 'ws';
|
||||
import {Client, type ClientChannel, type PseudoTtyOptions} from 'ssh2';
|
||||
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 => {
|
||||
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));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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());
|
||||
} catch (e) {
|
||||
logger.error('Invalid JSON received: ' + msg.toString());
|
||||
ws.send(JSON.stringify({type: 'error', message: 'Invalid JSON'}));
|
||||
return;
|
||||
}
|
||||
|
||||
const {type, data} = parsed;
|
||||
|
||||
switch (type) {
|
||||
case 'connectToHost':
|
||||
handleConnectToHost(data);
|
||||
break;
|
||||
|
||||
case 'resize':
|
||||
handleResize(data);
|
||||
break;
|
||||
|
||||
case 'disconnect':
|
||||
cleanupSSH();
|
||||
break;
|
||||
|
||||
case 'input':
|
||||
if (sshStream) {
|
||||
if (data === '\t') {
|
||||
sshStream.write(data);
|
||||
} else if (data.startsWith('\x1b')) {
|
||||
sshStream.write(data);
|
||||
} else {
|
||||
sshStream.write(Buffer.from(data, 'utf8'));
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ping':
|
||||
ws.send(JSON.stringify({type: 'pong'}));
|
||||
break;
|
||||
|
||||
default:
|
||||
logger.warn('Unknown message type: ' + type);
|
||||
}
|
||||
});
|
||||
|
||||
function handleConnectToHost(data: {
|
||||
cols: number;
|
||||
rows: number;
|
||||
hostConfig: {
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
authType?: string;
|
||||
};
|
||||
}) {
|
||||
const {cols, rows, hostConfig} = data;
|
||||
const {ip, port, username, password, key, keyPassword, keyType, authType} = hostConfig;
|
||||
|
||||
if (!username || typeof username !== 'string' || username.trim() === '') {
|
||||
logger.error('Invalid username provided');
|
||||
ws.send(JSON.stringify({type: 'error', message: 'Invalid username provided'}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ip || typeof ip !== 'string' || ip.trim() === '') {
|
||||
logger.error('Invalid IP provided');
|
||||
ws.send(JSON.stringify({type: 'error', message: 'Invalid IP provided'}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!port || typeof port !== 'number' || port <= 0) {
|
||||
logger.error('Invalid port provided');
|
||||
ws.send(JSON.stringify({type: 'error', message: 'Invalid port provided'}));
|
||||
return;
|
||||
}
|
||||
|
||||
sshConn = new Client();
|
||||
|
||||
const connectionTimeout = setTimeout(() => {
|
||||
if (sshConn) {
|
||||
logger.error('SSH connection timeout');
|
||||
ws.send(JSON.stringify({type: 'error', message: 'SSH connection timeout'}));
|
||||
cleanupSSH(connectionTimeout);
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
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) => {
|
||||
if (err) {
|
||||
logger.error('Shell error: ' + err.message);
|
||||
ws.send(JSON.stringify({type: 'error', message: 'Shell error: ' + err.message}));
|
||||
return;
|
||||
}
|
||||
|
||||
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('close', () => {
|
||||
cleanupSSH(connectionTimeout);
|
||||
});
|
||||
|
||||
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}));
|
||||
}
|
||||
});
|
||||
|
||||
setupPingInterval();
|
||||
|
||||
ws.send(JSON.stringify({type: 'connected', message: 'SSH connected'}));
|
||||
});
|
||||
});
|
||||
|
||||
sshConn.on('error', (err: Error) => {
|
||||
clearTimeout(connectionTimeout);
|
||||
logger.error('SSH connection error: ' + err.message);
|
||||
|
||||
let errorMessage = 'SSH error: ' + err.message;
|
||||
if (err.message.includes('No matching key exchange algorithm')) {
|
||||
errorMessage = 'SSH error: No compatible key exchange algorithm found. This may be due to an older SSH server or network device.';
|
||||
} else if (err.message.includes('No matching cipher')) {
|
||||
errorMessage = 'SSH error: No compatible cipher found. This may be due to an older SSH server or network device.';
|
||||
} else if (err.message.includes('No matching MAC')) {
|
||||
errorMessage = 'SSH error: No compatible MAC algorithm found. This may be due to an older SSH server or network device.';
|
||||
} else if (err.message.includes('ENOTFOUND') || err.message.includes('ENOENT')) {
|
||||
errorMessage = 'SSH error: Could not resolve hostname or connect to server.';
|
||||
} else if (err.message.includes('ECONNREFUSED')) {
|
||||
errorMessage = 'SSH error: Connection refused. The server may not be running or the port may be incorrect.';
|
||||
} else if (err.message.includes('ETIMEDOUT')) {
|
||||
errorMessage = 'SSH error: Connection timed out. Check your network connection and server availability.';
|
||||
} else if (err.message.includes('ECONNRESET') || err.message.includes('EPIPE')) {
|
||||
errorMessage = 'SSH error: Connection was reset. This may be due to network issues or server timeout.';
|
||||
} else if (err.message.includes('authentication failed') || err.message.includes('Permission denied')) {
|
||||
errorMessage = 'SSH error: Authentication failed. Please check your username and password/key.';
|
||||
}
|
||||
|
||||
ws.send(JSON.stringify({type: 'error', message: errorMessage}));
|
||||
cleanupSSH(connectionTimeout);
|
||||
});
|
||||
|
||||
sshConn.on('close', () => {
|
||||
clearTimeout(connectionTimeout);
|
||||
cleanupSSH(connectionTimeout);
|
||||
});
|
||||
|
||||
const connectConfig: any = {
|
||||
host: ip,
|
||||
port,
|
||||
username,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
readyTimeout: 10000,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
env: {
|
||||
TERM: 'xterm-256color',
|
||||
LANG: 'en_US.UTF-8',
|
||||
LC_ALL: 'en_US.UTF-8',
|
||||
LC_CTYPE: 'en_US.UTF-8',
|
||||
LC_MESSAGES: 'en_US.UTF-8',
|
||||
LC_MONETARY: 'en_US.UTF-8',
|
||||
LC_NUMERIC: 'en_US.UTF-8',
|
||||
LC_TIME: 'en_US.UTF-8',
|
||||
LC_COLLATE: 'en_US.UTF-8',
|
||||
COLORTERM: 'truecolor',
|
||||
},
|
||||
|
||||
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 (authType === 'key' && key) {
|
||||
connectConfig.privateKey = key;
|
||||
if (keyPassword) {
|
||||
connectConfig.passphrase = keyPassword;
|
||||
}
|
||||
if (keyType && keyType !== 'auto') {
|
||||
connectConfig.privateKeyType = keyType;
|
||||
}
|
||||
} else if (authType === 'key') {
|
||||
logger.error('SSH key authentication requested but no key provided');
|
||||
ws.send(JSON.stringify({type: 'error', message: 'SSH key authentication requested but no key provided'}));
|
||||
return;
|
||||
} else {
|
||||
connectConfig.password = password;
|
||||
}
|
||||
|
||||
sshConn.connect(connectConfig);
|
||||
}
|
||||
|
||||
function handleResize(data: { cols: number; rows: number }) {
|
||||
if (sshStream && sshStream.setWindow) {
|
||||
sshStream.setWindow(data.rows, data.cols, data.rows, data.cols);
|
||||
ws.send(JSON.stringify({type: 'resized', cols: data.cols, rows: data.rows}));
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupSSH(timeoutId?: NodeJS.Timeout) {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
if (pingInterval) {
|
||||
clearInterval(pingInterval);
|
||||
pingInterval = null;
|
||||
}
|
||||
|
||||
if (sshStream) {
|
||||
try {
|
||||
sshStream.end();
|
||||
} catch (e: any) {
|
||||
logger.error('Error closing stream: ' + e.message);
|
||||
}
|
||||
sshStream = null;
|
||||
}
|
||||
|
||||
if (sshConn) {
|
||||
try {
|
||||
sshConn.end();
|
||||
} catch (e: any) {
|
||||
logger.error('Error closing connection: ' + e.message);
|
||||
}
|
||||
sshConn = null;
|
||||
}
|
||||
}
|
||||
|
||||
function setupPingInterval() {
|
||||
pingInterval = setInterval(() => {
|
||||
if (sshConn && sshStream) {
|
||||
try {
|
||||
sshStream.write('\x00');
|
||||
} catch (e: any) {
|
||||
logger.error('SSH keepalive failed: ' + e.message);
|
||||
cleanupSSH();
|
||||
}
|
||||
}
|
||||
}, 60000);
|
||||
}
|
||||
});
|
||||
733
src/backend/ssh/terminal.ts
Normal file
@@ -0,0 +1,733 @@
|
||||
import { WebSocketServer, WebSocket, type RawData } from "ws";
|
||||
import { Client, type ClientChannel, type PseudoTtyOptions } from "ssh2";
|
||||
import { parse as parseUrl } from "url";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { sshCredentials } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { UserCrypto } from "../utils/user-crypto.js";
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const userCrypto = UserCrypto.getInstance();
|
||||
|
||||
const userConnections = new Map<string, Set<WebSocket>>();
|
||||
|
||||
const wss = new WebSocketServer({
|
||||
port: 30002,
|
||||
verifyClient: async (info) => {
|
||||
try {
|
||||
const url = parseUrl(info.req.url!, true);
|
||||
const token = url.query.token as string;
|
||||
|
||||
if (!token) {
|
||||
sshLogger.warn("WebSocket connection rejected: missing token", {
|
||||
operation: "websocket_auth_reject",
|
||||
reason: "missing_token",
|
||||
ip: info.req.socket.remoteAddress,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const payload = await authManager.verifyJWTToken(token);
|
||||
|
||||
if (!payload) {
|
||||
sshLogger.warn("WebSocket connection rejected: invalid token", {
|
||||
operation: "websocket_auth_reject",
|
||||
reason: "invalid_token",
|
||||
ip: info.req.socket.remoteAddress,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payload.pendingTOTP) {
|
||||
sshLogger.warn(
|
||||
"WebSocket connection rejected: TOTP verification pending",
|
||||
{
|
||||
operation: "websocket_auth_reject",
|
||||
reason: "totp_pending",
|
||||
userId: payload.userId,
|
||||
ip: info.req.socket.remoteAddress,
|
||||
},
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const existingConnections = userConnections.get(payload.userId);
|
||||
if (existingConnections && existingConnections.size >= 3) {
|
||||
sshLogger.warn("WebSocket connection rejected: too many connections", {
|
||||
operation: "websocket_auth_reject",
|
||||
reason: "connection_limit",
|
||||
userId: payload.userId,
|
||||
currentConnections: existingConnections.size,
|
||||
ip: info.req.socket.remoteAddress,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
sshLogger.error("WebSocket authentication error", error, {
|
||||
operation: "websocket_auth_error",
|
||||
ip: info.req.socket.remoteAddress,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
wss.on("connection", async (ws: WebSocket, req) => {
|
||||
let userId: string | undefined;
|
||||
let userPayload: any;
|
||||
|
||||
try {
|
||||
const url = parseUrl(req.url!, true);
|
||||
const token = url.query.token as string;
|
||||
|
||||
if (!token) {
|
||||
sshLogger.warn(
|
||||
"WebSocket connection rejected: missing token in connection",
|
||||
{
|
||||
operation: "websocket_connection_reject",
|
||||
reason: "missing_token",
|
||||
ip: req.socket.remoteAddress,
|
||||
},
|
||||
);
|
||||
ws.close(1008, "Authentication required");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await authManager.verifyJWTToken(token);
|
||||
if (!payload) {
|
||||
sshLogger.warn(
|
||||
"WebSocket connection rejected: invalid token in connection",
|
||||
{
|
||||
operation: "websocket_connection_reject",
|
||||
reason: "invalid_token",
|
||||
ip: req.socket.remoteAddress,
|
||||
},
|
||||
);
|
||||
ws.close(1008, "Authentication required");
|
||||
return;
|
||||
}
|
||||
|
||||
userId = payload.userId;
|
||||
userPayload = payload;
|
||||
} catch (error) {
|
||||
sshLogger.error(
|
||||
"WebSocket JWT verification failed during connection",
|
||||
error,
|
||||
{
|
||||
operation: "websocket_connection_auth_error",
|
||||
ip: req.socket.remoteAddress,
|
||||
},
|
||||
);
|
||||
ws.close(1008, "Authentication required");
|
||||
return;
|
||||
}
|
||||
|
||||
const dataKey = userCrypto.getUserDataKey(userId);
|
||||
if (!dataKey) {
|
||||
sshLogger.warn("WebSocket connection rejected: data locked", {
|
||||
operation: "websocket_data_locked",
|
||||
userId,
|
||||
ip: req.socket.remoteAddress,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Data locked - re-authenticate with password",
|
||||
code: "DATA_LOCKED",
|
||||
}),
|
||||
);
|
||||
ws.close(1008, "Data access required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!userConnections.has(userId)) {
|
||||
userConnections.set(userId, new Set());
|
||||
}
|
||||
const userWs = userConnections.get(userId)!;
|
||||
userWs.add(ws);
|
||||
|
||||
let sshConn: Client | null = null;
|
||||
let sshStream: ClientChannel | null = null;
|
||||
let pingInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
ws.on("close", () => {
|
||||
const userWs = userConnections.get(userId);
|
||||
if (userWs) {
|
||||
userWs.delete(ws);
|
||||
if (userWs.size === 0) {
|
||||
userConnections.delete(userId);
|
||||
}
|
||||
}
|
||||
|
||||
cleanupSSH();
|
||||
});
|
||||
|
||||
ws.on("message", (msg: RawData) => {
|
||||
const currentDataKey = userCrypto.getUserDataKey(userId);
|
||||
if (!currentDataKey) {
|
||||
sshLogger.warn("WebSocket message rejected: data access expired", {
|
||||
operation: "websocket_message_rejected",
|
||||
userId,
|
||||
reason: "data_access_expired",
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Data access expired - please re-authenticate",
|
||||
code: "DATA_EXPIRED",
|
||||
}),
|
||||
);
|
||||
ws.close(1008, "Data access expired");
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = JSON.parse(msg.toString());
|
||||
} catch (e) {
|
||||
sshLogger.error("Invalid JSON received", e, {
|
||||
operation: "websocket_message_invalid_json",
|
||||
userId,
|
||||
messageLength: msg.toString().length,
|
||||
});
|
||||
ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const { type, data } = parsed;
|
||||
|
||||
switch (type) {
|
||||
case "connectToHost":
|
||||
if (data.hostConfig) {
|
||||
data.hostConfig.userId = userId;
|
||||
}
|
||||
handleConnectToHost(data).catch((error) => {
|
||||
sshLogger.error("Failed to connect to host", error, {
|
||||
operation: "ssh_connect",
|
||||
userId,
|
||||
hostId: data.hostConfig?.id,
|
||||
ip: data.hostConfig?.ip,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message:
|
||||
"Failed to connect to host: " +
|
||||
(error instanceof Error ? error.message : "Unknown error"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
break;
|
||||
|
||||
case "resize":
|
||||
handleResize(data);
|
||||
break;
|
||||
|
||||
case "disconnect":
|
||||
cleanupSSH();
|
||||
break;
|
||||
|
||||
case "input":
|
||||
if (sshStream) {
|
||||
if (data === "\t") {
|
||||
sshStream.write(data);
|
||||
} else if (data.startsWith("\x1b")) {
|
||||
sshStream.write(data);
|
||||
} else {
|
||||
try {
|
||||
sshStream.write(Buffer.from(data, "utf8"));
|
||||
} catch (error) {
|
||||
sshLogger.error("Error writing input to SSH stream", error, {
|
||||
operation: "ssh_input_encoding",
|
||||
userId,
|
||||
dataLength: data.length,
|
||||
});
|
||||
sshStream.write(Buffer.from(data, "latin1"));
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "ping":
|
||||
ws.send(JSON.stringify({ type: "pong" }));
|
||||
break;
|
||||
|
||||
default:
|
||||
sshLogger.warn("Unknown message type received", {
|
||||
operation: "websocket_message_unknown_type",
|
||||
userId,
|
||||
messageType: type,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function handleConnectToHost(data: {
|
||||
cols: number;
|
||||
rows: number;
|
||||
hostConfig: {
|
||||
id: number;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
authType?: string;
|
||||
credentialId?: number;
|
||||
userId?: string;
|
||||
};
|
||||
initialPath?: string;
|
||||
executeCommand?: string;
|
||||
}) {
|
||||
const { cols, rows, hostConfig, initialPath, executeCommand } = data;
|
||||
const {
|
||||
id,
|
||||
ip,
|
||||
port,
|
||||
username,
|
||||
password,
|
||||
key,
|
||||
keyPassword,
|
||||
keyType,
|
||||
authType,
|
||||
credentialId,
|
||||
} = hostConfig;
|
||||
|
||||
if (!username || typeof username !== "string" || username.trim() === "") {
|
||||
sshLogger.error("Invalid username provided", undefined, {
|
||||
operation: "ssh_connect",
|
||||
hostId: id,
|
||||
ip,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({ type: "error", message: "Invalid username provided" }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ip || typeof ip !== "string" || ip.trim() === "") {
|
||||
sshLogger.error("Invalid IP provided", undefined, {
|
||||
operation: "ssh_connect",
|
||||
hostId: id,
|
||||
username,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({ type: "error", message: "Invalid IP provided" }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!port || typeof port !== "number" || port <= 0) {
|
||||
sshLogger.error("Invalid port provided", undefined, {
|
||||
operation: "ssh_connect",
|
||||
hostId: id,
|
||||
ip,
|
||||
username,
|
||||
port,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({ type: "error", message: "Invalid port provided" }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
sshConn = new Client();
|
||||
|
||||
const connectionTimeout = setTimeout(() => {
|
||||
if (sshConn) {
|
||||
sshLogger.error("SSH connection timeout", undefined, {
|
||||
operation: "ssh_connect",
|
||||
hostId: id,
|
||||
ip,
|
||||
port,
|
||||
username,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({ type: "error", message: "SSH connection timeout" }),
|
||||
);
|
||||
cleanupSSH(connectionTimeout);
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
let resolvedCredentials = { password, key, keyPassword, keyType, authType };
|
||||
if (credentialId && id && hostConfig.userId) {
|
||||
try {
|
||||
const credentials = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, credentialId),
|
||||
eq(sshCredentials.userId, hostConfig.userId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
hostConfig.userId,
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
resolvedCredentials = {
|
||||
password: credential.password,
|
||||
key:
|
||||
credential.private_key || credential.privateKey || credential.key,
|
||||
keyPassword: credential.key_password || credential.keyPassword,
|
||||
keyType: credential.key_type || credential.keyType,
|
||||
authType: credential.auth_type || credential.authType,
|
||||
};
|
||||
} else {
|
||||
sshLogger.warn(`No credentials found for host ${id}`, {
|
||||
operation: "ssh_credentials",
|
||||
hostId: id,
|
||||
credentialId,
|
||||
userId: hostConfig.userId,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
sshLogger.warn(`Failed to resolve credentials for host ${id}`, {
|
||||
operation: "ssh_credentials",
|
||||
hostId: id,
|
||||
credentialId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
} else if (credentialId && id) {
|
||||
sshLogger.warn("Missing userId for credential resolution in terminal", {
|
||||
operation: "ssh_credentials",
|
||||
hostId: id,
|
||||
credentialId,
|
||||
hasUserId: !!hostConfig.userId,
|
||||
});
|
||||
}
|
||||
|
||||
sshConn.on("ready", () => {
|
||||
clearTimeout(connectionTimeout);
|
||||
|
||||
sshConn!.shell(
|
||||
{
|
||||
rows: data.rows,
|
||||
cols: data.cols,
|
||||
term: "xterm-256color",
|
||||
} as PseudoTtyOptions,
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
sshLogger.error("Shell error", err, {
|
||||
operation: "ssh_shell",
|
||||
hostId: id,
|
||||
ip,
|
||||
port,
|
||||
username,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Shell error: " + err.message,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
sshStream = stream;
|
||||
|
||||
stream.on("data", (data: Buffer) => {
|
||||
try {
|
||||
const utf8String = data.toString("utf-8");
|
||||
ws.send(JSON.stringify({ type: "data", data: utf8String }));
|
||||
} catch (error) {
|
||||
sshLogger.error("Error encoding terminal data", error, {
|
||||
operation: "terminal_data_encoding",
|
||||
hostId: id,
|
||||
dataLength: data.length,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "data",
|
||||
data: data.toString("latin1"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("close", () => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "disconnected",
|
||||
message: "Connection lost",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
stream.on("error", (err: Error) => {
|
||||
sshLogger.error("SSH stream error", err, {
|
||||
operation: "ssh_stream",
|
||||
hostId: id,
|
||||
ip,
|
||||
port,
|
||||
username,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "SSH stream error: " + err.message,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
setupPingInterval();
|
||||
|
||||
if (initialPath && initialPath.trim() !== "") {
|
||||
const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}" && pwd\n`;
|
||||
stream.write(cdCommand);
|
||||
}
|
||||
|
||||
if (executeCommand && executeCommand.trim() !== "") {
|
||||
setTimeout(() => {
|
||||
const command = `${executeCommand}\n`;
|
||||
stream.write(command);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({ type: "connected", message: "SSH connected" }),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
sshConn.on("error", (err: Error) => {
|
||||
clearTimeout(connectionTimeout);
|
||||
sshLogger.error("SSH connection error", err, {
|
||||
operation: "ssh_connect",
|
||||
hostId: id,
|
||||
ip,
|
||||
port,
|
||||
username,
|
||||
authType: resolvedCredentials.authType,
|
||||
});
|
||||
|
||||
let errorMessage = "SSH error: " + err.message;
|
||||
if (err.message.includes("No matching key exchange algorithm")) {
|
||||
errorMessage =
|
||||
"SSH error: No compatible key exchange algorithm found. This may be due to an older SSH server or network device.";
|
||||
} else if (err.message.includes("No matching cipher")) {
|
||||
errorMessage =
|
||||
"SSH error: No compatible cipher found. This may be due to an older SSH server or network device.";
|
||||
} else if (err.message.includes("No matching MAC")) {
|
||||
errorMessage =
|
||||
"SSH error: No compatible MAC algorithm found. This may be due to an older SSH server or network device.";
|
||||
} else if (
|
||||
err.message.includes("ENOTFOUND") ||
|
||||
err.message.includes("ENOENT")
|
||||
) {
|
||||
errorMessage =
|
||||
"SSH error: Could not resolve hostname or connect to server.";
|
||||
} else if (err.message.includes("ECONNREFUSED")) {
|
||||
errorMessage =
|
||||
"SSH error: Connection refused. The server may not be running or the port may be incorrect.";
|
||||
} else if (err.message.includes("ETIMEDOUT")) {
|
||||
errorMessage =
|
||||
"SSH error: Connection timed out. Check your network connection and server availability.";
|
||||
} else if (
|
||||
err.message.includes("ECONNRESET") ||
|
||||
err.message.includes("EPIPE")
|
||||
) {
|
||||
errorMessage =
|
||||
"SSH error: Connection was reset. This may be due to network issues or server timeout.";
|
||||
} else if (
|
||||
err.message.includes("authentication failed") ||
|
||||
err.message.includes("Permission denied")
|
||||
) {
|
||||
errorMessage =
|
||||
"SSH error: Authentication failed. Please check your username and password/key.";
|
||||
}
|
||||
|
||||
ws.send(JSON.stringify({ type: "error", message: errorMessage }));
|
||||
cleanupSSH(connectionTimeout);
|
||||
});
|
||||
|
||||
sshConn.on("close", () => {
|
||||
clearTimeout(connectionTimeout);
|
||||
cleanupSSH(connectionTimeout);
|
||||
});
|
||||
|
||||
const connectConfig: any = {
|
||||
host: ip,
|
||||
port,
|
||||
username,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
readyTimeout: 60000,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
|
||||
env: {
|
||||
TERM: "xterm-256color",
|
||||
LANG: "en_US.UTF-8",
|
||||
LC_ALL: "en_US.UTF-8",
|
||||
LC_CTYPE: "en_US.UTF-8",
|
||||
LC_MESSAGES: "en_US.UTF-8",
|
||||
LC_MONETARY: "en_US.UTF-8",
|
||||
LC_NUMERIC: "en_US.UTF-8",
|
||||
LC_TIME: "en_US.UTF-8",
|
||||
LC_COLLATE: "en_US.UTF-8",
|
||||
COLORTERM: "truecolor",
|
||||
},
|
||||
|
||||
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-etm@openssh.com",
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha2-512",
|
||||
"hmac-sha1",
|
||||
"hmac-md5",
|
||||
],
|
||||
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||
},
|
||||
};
|
||||
if (
|
||||
resolvedCredentials.authType === "password" &&
|
||||
resolvedCredentials.password
|
||||
) {
|
||||
connectConfig.password = resolvedCredentials.password;
|
||||
} else if (
|
||||
resolvedCredentials.authType === "key" &&
|
||||
resolvedCredentials.key
|
||||
) {
|
||||
try {
|
||||
if (
|
||||
!resolvedCredentials.key.includes("-----BEGIN") ||
|
||||
!resolvedCredentials.key.includes("-----END")
|
||||
) {
|
||||
throw new Error("Invalid private key format");
|
||||
}
|
||||
|
||||
const cleanKey = resolvedCredentials.key
|
||||
.trim()
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n");
|
||||
|
||||
connectConfig.privateKey = Buffer.from(cleanKey, "utf8");
|
||||
|
||||
if (resolvedCredentials.keyPassword) {
|
||||
connectConfig.passphrase = resolvedCredentials.keyPassword;
|
||||
}
|
||||
|
||||
if (
|
||||
resolvedCredentials.keyType &&
|
||||
resolvedCredentials.keyType !== "auto"
|
||||
) {
|
||||
connectConfig.privateKeyType = resolvedCredentials.keyType;
|
||||
}
|
||||
} catch (keyError) {
|
||||
sshLogger.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 (resolvedCredentials.authType === "key") {
|
||||
sshLogger.error("SSH key authentication requested but no key provided");
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "SSH key authentication requested but no key provided",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
sshLogger.error("No valid authentication method provided");
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "No valid authentication method provided",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
sshConn.connect(connectConfig);
|
||||
}
|
||||
|
||||
function handleResize(data: { cols: number; rows: number }) {
|
||||
if (sshStream && sshStream.setWindow) {
|
||||
sshStream.setWindow(data.rows, data.cols, data.rows, data.cols);
|
||||
ws.send(
|
||||
JSON.stringify({ type: "resized", cols: data.cols, rows: data.rows }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupSSH(timeoutId?: NodeJS.Timeout) {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
if (pingInterval) {
|
||||
clearInterval(pingInterval);
|
||||
pingInterval = null;
|
||||
}
|
||||
|
||||
if (sshStream) {
|
||||
try {
|
||||
sshStream.end();
|
||||
} catch (e: any) {
|
||||
sshLogger.error("Error closing stream: " + e.message);
|
||||
}
|
||||
sshStream = null;
|
||||
}
|
||||
|
||||
if (sshConn) {
|
||||
try {
|
||||
sshConn.end();
|
||||
} catch (e: any) {
|
||||
sshLogger.error("Error closing connection: " + e.message);
|
||||
}
|
||||
sshConn = null;
|
||||
}
|
||||
}
|
||||
|
||||
function setupPingInterval() {
|
||||
pingInterval = setInterval(() => {
|
||||
if (sshConn && sshStream) {
|
||||
try {
|
||||
sshStream.write("\x00");
|
||||
} catch (e: any) {
|
||||
sshLogger.error("SSH keepalive failed: " + e.message);
|
||||
cleanupSSH();
|
||||
}
|
||||
}
|
||||
}, 60000);
|
||||
}
|
||||
});
|
||||