Clean up files, fix bugs in file manager, update api ports, etc.
This commit is contained in:
145
.dockerignore
145
.dockerignore
@@ -1,17 +1,20 @@
|
|||||||
# Git and version control
|
# Dependencies
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
|
|
||||||
# Node.js
|
|
||||||
node_modules
|
node_modules
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|
||||||
# Environment and configuration
|
# Build outputs
|
||||||
.env
|
dist
|
||||||
.env.*
|
build
|
||||||
!.env.example
|
.next
|
||||||
|
.nuxt
|
||||||
|
|
||||||
|
# Development files
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
# IDE and editor files
|
# IDE and editor files
|
||||||
.vscode
|
.vscode
|
||||||
@@ -29,6 +32,28 @@ yarn-error.log*
|
|||||||
ehthumbs.db
|
ehthumbs.db
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
README.md
|
||||||
|
README-CN.md
|
||||||
|
CONTRIBUTING.md
|
||||||
|
LICENSE
|
||||||
|
|
||||||
|
# Docker files (avoid copying docker files into docker)
|
||||||
|
docker/
|
||||||
|
|
||||||
|
# Repository images
|
||||||
|
repo-images/
|
||||||
|
|
||||||
|
# Uploads directory
|
||||||
|
uploads/
|
||||||
|
|
||||||
|
# Fonts (Electron-only, not needed for Docker)
|
||||||
|
public/fonts/
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
@@ -42,66 +67,54 @@ pids
|
|||||||
# Coverage directory used by tools like istanbul
|
# Coverage directory used by tools like istanbul
|
||||||
coverage
|
coverage
|
||||||
|
|
||||||
# Build directories (we build inside Docker)
|
# nyc test coverage
|
||||||
dist/
|
.nyc_output
|
||||||
build/
|
|
||||||
|
|
||||||
# Temporary files
|
# Dependency directories
|
||||||
tmp/
|
jspm_packages/
|
||||||
temp/
|
|
||||||
|
|
||||||
# SSL certificates (generated at runtime)
|
# Optional npm cache directory
|
||||||
ssl/
|
.npm
|
||||||
*.crt
|
|
||||||
*.key
|
|
||||||
*.pem
|
|
||||||
|
|
||||||
# Database files (use volumes)
|
# Optional eslint cache
|
||||||
*.sqlite
|
|
||||||
*.db
|
|
||||||
|
|
||||||
# Docker files (avoid recursion)
|
|
||||||
Dockerfile*
|
|
||||||
docker-compose*.yml
|
|
||||||
.dockerignore
|
|
||||||
|
|
||||||
# Documentation
|
|
||||||
README*.md
|
|
||||||
CONTRIBUTING.md
|
|
||||||
LICENSE
|
|
||||||
*.md
|
|
||||||
|
|
||||||
# Repository images and assets (not needed in container)
|
|
||||||
repo-images/
|
|
||||||
|
|
||||||
# Testing
|
|
||||||
test/
|
|
||||||
tests/
|
|
||||||
*.test.js
|
|
||||||
*.spec.js
|
|
||||||
|
|
||||||
# Uploads directory (use volumes)
|
|
||||||
uploads/
|
|
||||||
|
|
||||||
# Backup files
|
|
||||||
*.bak
|
|
||||||
*.backup
|
|
||||||
*.old
|
|
||||||
|
|
||||||
# Cache directories
|
|
||||||
.cache/
|
|
||||||
.npm/
|
|
||||||
.yarn/
|
|
||||||
|
|
||||||
# TypeScript build info
|
|
||||||
*.tsbuildinfo
|
|
||||||
|
|
||||||
# ESLint cache
|
|
||||||
.eslintcache
|
.eslintcache
|
||||||
|
|
||||||
# Prettier
|
# Microbundle cache
|
||||||
.prettierignore
|
.rpt2_cache/
|
||||||
.prettierrc*
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
|
||||||
# Local configuration
|
# Optional REPL history
|
||||||
.claude/
|
.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
|
||||||
35
.github/workflows/docker-image.yml
vendored
35
.github/workflows/docker-image.yml
vendored
@@ -1,18 +1,20 @@
|
|||||||
name: Build and Push Docker Image
|
name: Build and Push Docker Image
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- development
|
|
||||||
paths-ignore:
|
|
||||||
- "**.md"
|
|
||||||
- ".gitignore"
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
tag_name:
|
tag_name:
|
||||||
description: "Custom tag name for the Docker image"
|
description: "Custom tag name for the Docker image"
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
|
registry:
|
||||||
|
description: "Docker registry to push to"
|
||||||
|
required: true
|
||||||
|
default: "ghcr"
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- "ghcr"
|
||||||
|
- "dockerhub"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -57,12 +59,20 @@ jobs:
|
|||||||
${{ runner.os }}-buildx-
|
${{ runner.os }}-buildx-
|
||||||
|
|
||||||
- name: Login to Docker Registry
|
- name: Login to Docker Registry
|
||||||
|
if: github.event.inputs.registry == 'dockerhub' || github.event_name == 'push'
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
if: github.event.inputs.registry == 'dockerhub'
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Determine Docker image tag
|
- name: Determine Docker image tag
|
||||||
run: |
|
run: |
|
||||||
echo "REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
echo "REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
||||||
@@ -76,6 +86,15 @@ jobs:
|
|||||||
IMAGE_TAG="${{ github.ref_name }}"
|
IMAGE_TAG="${{ github.ref_name }}"
|
||||||
fi
|
fi
|
||||||
echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV
|
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=${{ secrets.DOCKERHUB_USERNAME }}/termix" >> $GITHUB_ENV
|
||||||
|
else
|
||||||
|
echo "REGISTRY=ghcr.io" >> $GITHUB_ENV
|
||||||
|
echo "IMAGE_NAME=${{ env.REPO_OWNER }}/termix" >> $GITHUB_ENV
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Build and Push Multi-Arch Docker Image
|
- name: Build and Push Multi-Arch Docker Image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -84,7 +103,7 @@ jobs:
|
|||||||
file: ./docker/Dockerfile
|
file: ./docker/Dockerfile
|
||||||
push: true
|
push: true
|
||||||
platforms: linux/amd64,linux/arm64
|
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: |
|
labels: |
|
||||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||||
org.opencontainers.image.revision=${{ github.sha }}
|
org.opencontainers.image.revision=${{ github.sha }}
|
||||||
@@ -101,7 +120,7 @@ jobs:
|
|||||||
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
|
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
|
||||||
|
|
||||||
- name: Delete all untagged image versions
|
- name: Delete all untagged image versions
|
||||||
if: success()
|
if: success() && (github.event.inputs.registry != 'dockerhub' && github.event_name == 'push')
|
||||||
uses: quartx-analytics/ghcr-cleaner@v1
|
uses: quartx-analytics/ghcr-cleaner@v1
|
||||||
with:
|
with:
|
||||||
owner-type: user
|
owner-type: user
|
||||||
|
|||||||
@@ -35,11 +35,11 @@ RUN npm rebuild better-sqlite3 --force
|
|||||||
|
|
||||||
RUN npm run build:backend
|
RUN npm run build:backend
|
||||||
|
|
||||||
# Stage 4: Production dependencies with native modules
|
# Stage 4: Production dependencies only
|
||||||
FROM node:24-alpine AS production-deps
|
FROM node:24-alpine AS production-deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apk add --no-cache python3 make g++
|
RUN apk add --no-cache python3 make g++
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
@@ -47,42 +47,37 @@ ENV npm_config_target_platform=linux
|
|||||||
ENV npm_config_target_arch=x64
|
ENV npm_config_target_arch=x64
|
||||||
ENV npm_config_target_libc=glibc
|
ENV npm_config_target_libc=glibc
|
||||||
|
|
||||||
# Install production dependencies and rebuild native modules in one stage
|
RUN npm ci --only=production --ignore-scripts --force && \
|
||||||
RUN npm ci --omit=dev --ignore-scripts --force && \
|
|
||||||
npm rebuild better-sqlite3 bcryptjs --force && \
|
npm rebuild better-sqlite3 bcryptjs --force && \
|
||||||
npm cache clean --force && \
|
npm cache clean --force
|
||||||
rm -rf ~/.npm /tmp/* /var/cache/apk/*
|
|
||||||
|
|
||||||
# Stage 6: Final image
|
# Stage 5: Final optimized image
|
||||||
FROM node:24-alpine
|
FROM node:24-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
ENV DATA_DIR=/app/data \
|
ENV DATA_DIR=/app/data \
|
||||||
PORT=8080 \
|
PORT=8080 \
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
|
|
||||||
RUN apk add --no-cache nginx gettext su-exec openssl && \
|
RUN apk add --no-cache nginx gettext su-exec && \
|
||||||
mkdir -p /app/data /app/config /app/ssl && \
|
mkdir -p /app/data && \
|
||||||
chown -R node:node /app/data /app/config /app/ssl
|
chown -R node:node /app/data
|
||||||
|
|
||||||
COPY docker/nginx.conf /etc/nginx/nginx.conf
|
COPY docker/nginx.conf /etc/nginx/nginx.conf
|
||||||
COPY docker/nginx-https.conf /etc/nginx/nginx-https.conf
|
|
||||||
COPY --from=frontend-builder /app/dist /usr/share/nginx/html
|
COPY --from=frontend-builder /app/dist /usr/share/nginx/html
|
||||||
COPY --from=frontend-builder /app/src/locales /usr/share/nginx/html/locales
|
COPY --from=frontend-builder /app/src/locales /usr/share/nginx/html/locales
|
||||||
RUN chown -R nginx:nginx /usr/share/nginx/html
|
RUN chown -R nginx:nginx /usr/share/nginx/html
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY --from=production-deps /app/node_modules /app/node_modules
|
COPY --from=production-deps /app/node_modules /app/node_modules
|
||||||
COPY --from=backend-builder /app/dist/backend ./dist/backend
|
COPY --from=backend-builder /app/dist/backend ./dist/backend
|
||||||
|
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
RUN chown -R node:node /app && \
|
COPY .env ./.env
|
||||||
chmod 755 /app/config && \
|
RUN chown -R node:node /app
|
||||||
chmod 755 /app/ssl && \
|
|
||||||
chmod 755 /app/data
|
|
||||||
|
|
||||||
VOLUME ["/app/data"]
|
VOLUME ["/app/data"]
|
||||||
|
|
||||||
EXPOSE ${PORT} 8081 8082 8083 8084 8085
|
EXPOSE ${PORT} 30001 30002 30003 30004 30005
|
||||||
|
|
||||||
COPY docker/entrypoint.sh /entrypoint.sh
|
COPY docker/entrypoint.sh /entrypoint.sh
|
||||||
RUN chmod +x /entrypoint.sh
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/users(/.*)?$ {
|
location ~ ^/users(/.*)?$ {
|
||||||
proxy_pass http://127.0.0.1:8081;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -55,7 +55,7 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/version(/.*)?$ {
|
location ~ ^/version(/.*)?$ {
|
||||||
proxy_pass http://127.0.0.1:8081;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -64,7 +64,7 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/releases(/.*)?$ {
|
location ~ ^/releases(/.*)?$ {
|
||||||
proxy_pass http://127.0.0.1:8081;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -73,7 +73,7 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/alerts(/.*)?$ {
|
location ~ ^/alerts(/.*)?$ {
|
||||||
proxy_pass http://127.0.0.1:8081;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -82,7 +82,7 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/credentials(/.*)?$ {
|
location ~ ^/credentials(/.*)?$ {
|
||||||
proxy_pass http://127.0.0.1:8081;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -91,7 +91,7 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /ssh/ {
|
location /ssh/ {
|
||||||
proxy_pass http://127.0.0.1:8081;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -102,7 +102,7 @@ http {
|
|||||||
# WebSocket proxy for authenticated terminal connections
|
# WebSocket proxy for authenticated terminal connections
|
||||||
location /ssh/websocket/ {
|
location /ssh/websocket/ {
|
||||||
# Pass to WebSocket server with authentication support
|
# Pass to WebSocket server with authentication support
|
||||||
proxy_pass http://127.0.0.1:8082/;
|
proxy_pass http://127.0.0.1:30002/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
# WebSocket upgrade headers
|
# WebSocket upgrade headers
|
||||||
@@ -132,7 +132,7 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /ssh/tunnel/ {
|
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_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -141,7 +141,7 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /ssh/file_manager/recent {
|
location /ssh/file_manager/recent {
|
||||||
proxy_pass http://127.0.0.1:8081;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -150,7 +150,7 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /ssh/file_manager/pinned {
|
location /ssh/file_manager/pinned {
|
||||||
proxy_pass http://127.0.0.1:8081;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -159,7 +159,7 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /ssh/file_manager/shortcuts {
|
location /ssh/file_manager/shortcuts {
|
||||||
proxy_pass http://127.0.0.1:8081;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -168,7 +168,7 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /ssh/file_manager/ssh/ {
|
location /ssh/file_manager/ssh/ {
|
||||||
proxy_pass http://127.0.0.1:8084;
|
proxy_pass http://127.0.0.1:30004;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -177,7 +177,7 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /health {
|
location /health {
|
||||||
proxy_pass http://127.0.0.1:8081;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -186,7 +186,7 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/status(/.*)?$ {
|
location ~ ^/status(/.*)?$ {
|
||||||
proxy_pass http://127.0.0.1:8085;
|
proxy_pass http://127.0.0.1:30005;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -195,7 +195,7 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/metrics(/.*)?$ {
|
location ~ ^/metrics(/.*)?$ {
|
||||||
proxy_pass http://127.0.0.1:8085;
|
proxy_pass http://127.0.0.1:30005;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/users(/.*)?$ {
|
location ~ ^/users(/.*)?$ {
|
||||||
proxy_pass http://127.0.0.1:8081;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -41,7 +41,7 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/version(/.*)?$ {
|
location ~ ^/version(/.*)?$ {
|
||||||
proxy_pass http://127.0.0.1:8081;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -50,7 +50,7 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/releases(/.*)?$ {
|
location ~ ^/releases(/.*)?$ {
|
||||||
proxy_pass http://127.0.0.1:8081;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -59,7 +59,7 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/alerts(/.*)?$ {
|
location ~ ^/alerts(/.*)?$ {
|
||||||
proxy_pass http://127.0.0.1:8081;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -68,7 +68,7 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/credentials(/.*)?$ {
|
location ~ ^/credentials(/.*)?$ {
|
||||||
proxy_pass http://127.0.0.1:8081;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -77,7 +77,7 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /ssh/ {
|
location /ssh/ {
|
||||||
proxy_pass http://127.0.0.1:8081;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -88,7 +88,7 @@ http {
|
|||||||
# WebSocket proxy for authenticated terminal connections
|
# WebSocket proxy for authenticated terminal connections
|
||||||
location /ssh/websocket/ {
|
location /ssh/websocket/ {
|
||||||
# Pass to WebSocket server with authentication support
|
# Pass to WebSocket server with authentication support
|
||||||
proxy_pass http://127.0.0.1:8082/;
|
proxy_pass http://127.0.0.1:30002/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
# WebSocket upgrade headers
|
# WebSocket upgrade headers
|
||||||
@@ -118,7 +118,7 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /ssh/tunnel/ {
|
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_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -127,7 +127,7 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /ssh/file_manager/recent {
|
location /ssh/file_manager/recent {
|
||||||
proxy_pass http://127.0.0.1:8081;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -136,7 +136,7 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /ssh/file_manager/pinned {
|
location /ssh/file_manager/pinned {
|
||||||
proxy_pass http://127.0.0.1:8081;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -145,7 +145,7 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /ssh/file_manager/shortcuts {
|
location /ssh/file_manager/shortcuts {
|
||||||
proxy_pass http://127.0.0.1:8081;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -154,7 +154,7 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /ssh/file_manager/ssh/ {
|
location /ssh/file_manager/ssh/ {
|
||||||
proxy_pass http://127.0.0.1:8084;
|
proxy_pass http://127.0.0.1:30004;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -163,7 +163,7 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /health {
|
location /health {
|
||||||
proxy_pass http://127.0.0.1:8081;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -172,7 +172,7 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/status(/.*)?$ {
|
location ~ ^/status(/.*)?$ {
|
||||||
proxy_pass http://127.0.0.1:8085;
|
proxy_pass http://127.0.0.1:30005;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -181,7 +181,7 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/metrics(/.*)?$ {
|
location ~ ^/metrics(/.*)?$ {
|
||||||
proxy_pass http://127.0.0.1:8085;
|
proxy_pass http://127.0.0.1:30005;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|||||||
@@ -1263,7 +1263,7 @@ app.use(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const HTTP_PORT = 8081;
|
const HTTP_PORT = 30001;
|
||||||
const HTTPS_PORT = process.env.SSL_PORT || 8443;
|
const HTTPS_PORT = process.env.SSL_PORT || 8443;
|
||||||
|
|
||||||
async function initializeSecurity() {
|
async function initializeSecurity() {
|
||||||
|
|||||||
@@ -148,8 +148,8 @@ async function initializeDatabaseAsync(): Promise<void> {
|
|||||||
backupPath: migrationResult.backupPath,
|
backupPath: migrationResult.backupPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🔥 CRITICAL: Migration failure with existing data
|
// CRITICAL: Migration failure with existing data
|
||||||
console.error("🚨 DATABASE MIGRATION FAILED - THIS IS CRITICAL!");
|
console.error("DATABASE MIGRATION FAILED - THIS IS CRITICAL!");
|
||||||
console.error("Migration error:", migrationResult.error);
|
console.error("Migration error:", migrationResult.error);
|
||||||
console.error("Backup available at:", migrationResult.backupPath);
|
console.error("Backup available at:", migrationResult.backupPath);
|
||||||
console.error("Manual intervention required to recover data.");
|
console.error("Manual intervention required to recover data.");
|
||||||
@@ -177,9 +177,9 @@ async function initializeDatabaseAsync(): Promise<void> {
|
|||||||
databaseKeyLength: process.env.DATABASE_KEY?.length || 0,
|
databaseKeyLength: process.env.DATABASE_KEY?.length || 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🔥 CRITICAL: Never silently ignore database decryption failures!
|
// CRITICAL: Never silently ignore database decryption failures!
|
||||||
// This causes complete data loss for users
|
// This causes complete data loss for users
|
||||||
console.error("🚨 DATABASE DECRYPTION FAILED - THIS IS CRITICAL!");
|
console.error("DATABASE DECRYPTION FAILED - THIS IS CRITICAL!");
|
||||||
console.error("Error details:", error instanceof Error ? error.message : error);
|
console.error("Error details:", error instanceof Error ? error.message : error);
|
||||||
console.error("Encrypted file exists:", DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath));
|
console.error("Encrypted file exists:", DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath));
|
||||||
console.error("DATABASE_KEY available:", !!process.env.DATABASE_KEY);
|
console.error("DATABASE_KEY available:", !!process.env.DATABASE_KEY);
|
||||||
@@ -382,11 +382,6 @@ const addColumnIfNotExists = (
|
|||||||
.get();
|
.get();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
try {
|
try {
|
||||||
databaseLogger.debug(`Adding column ${column} to ${table}`, {
|
|
||||||
operation: "schema_migration",
|
|
||||||
table,
|
|
||||||
column,
|
|
||||||
});
|
|
||||||
sqlite.exec(`ALTER TABLE ${table}
|
sqlite.exec(`ALTER TABLE ${table}
|
||||||
ADD COLUMN ${column} ${definition};`);
|
ADD COLUMN ${column} ${definition};`);
|
||||||
databaseLogger.success(`Column ${column} added to ${table}`, {
|
databaseLogger.success(`Column ${column} added to ${table}`, {
|
||||||
@@ -515,22 +510,9 @@ async function saveMemoryDatabaseToFile() {
|
|||||||
if (enableFileEncryption) {
|
if (enableFileEncryption) {
|
||||||
// Save as encrypted file
|
// Save as encrypted file
|
||||||
await DatabaseFileEncryption.encryptDatabaseFromBuffer(buffer, encryptedDbPath);
|
await DatabaseFileEncryption.encryptDatabaseFromBuffer(buffer, encryptedDbPath);
|
||||||
|
|
||||||
databaseLogger.debug("In-memory database saved to encrypted file", {
|
|
||||||
operation: "memory_db_save_encrypted",
|
|
||||||
bufferSize: buffer.length,
|
|
||||||
encryptedPath: encryptedDbPath,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
// Fallback: save as unencrypted SQLite file to prevent data loss
|
// Fallback: save as unencrypted SQLite file to prevent data loss
|
||||||
fs.writeFileSync(dbPath, buffer);
|
fs.writeFileSync(dbPath, buffer);
|
||||||
|
|
||||||
databaseLogger.debug("In-memory database saved to unencrypted file", {
|
|
||||||
operation: "memory_db_save_unencrypted",
|
|
||||||
bufferSize: buffer.length,
|
|
||||||
unencryptedPath: dbPath,
|
|
||||||
warning: "File encryption disabled - data saved unencrypted",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error("Failed to save in-memory database", error, {
|
databaseLogger.error("Failed to save in-memory database", error, {
|
||||||
@@ -643,9 +625,6 @@ async function cleanupDatabase() {
|
|||||||
try {
|
try {
|
||||||
if (sqlite) {
|
if (sqlite) {
|
||||||
sqlite.close();
|
sqlite.close();
|
||||||
databaseLogger.debug("Database connection closed", {
|
|
||||||
operation: "db_close",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.warn("Error closing database connection", {
|
databaseLogger.warn("Error closing database connection", {
|
||||||
@@ -669,9 +648,6 @@ async function cleanupDatabase() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
fs.rmdirSync(tempDir);
|
fs.rmdirSync(tempDir);
|
||||||
databaseLogger.debug("Temp directory cleaned up", {
|
|
||||||
operation: "temp_dir_cleanup",
|
|
||||||
});
|
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore directory removal errors
|
// Ignore directory removal errors
|
||||||
}
|
}
|
||||||
@@ -745,12 +721,6 @@ function getMemoryDatabaseBuffer(): Buffer {
|
|||||||
try {
|
try {
|
||||||
// Export in-memory database to buffer
|
// Export in-memory database to buffer
|
||||||
const buffer = memoryDatabase.serialize();
|
const buffer = memoryDatabase.serialize();
|
||||||
|
|
||||||
databaseLogger.debug("Memory database serialized to buffer", {
|
|
||||||
operation: "memory_db_serialize",
|
|
||||||
bufferSize: buffer.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
return buffer;
|
return buffer;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error(
|
databaseLogger.error(
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ function generateSSHKeyPair(
|
|||||||
publicKey?: string;
|
publicKey?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
} {
|
} {
|
||||||
console.log("Generating SSH key pair with ssh2:", keyType);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Convert our keyType to ssh2 format
|
// Convert our keyType to ssh2 format
|
||||||
let ssh2Type = keyType;
|
let ssh2Type = keyType;
|
||||||
@@ -54,17 +52,12 @@ function generateSSHKeyPair(
|
|||||||
// Use ssh2's native key generation
|
// Use ssh2's native key generation
|
||||||
const keyPair = ssh2Utils.generateKeyPairSync(ssh2Type as any, options);
|
const keyPair = ssh2Utils.generateKeyPairSync(ssh2Type as any, options);
|
||||||
|
|
||||||
console.log("SSH key pair generated successfully!");
|
|
||||||
console.log("Private key length:", keyPair.private.length);
|
|
||||||
console.log("Public key preview:", keyPair.public.substring(0, 50) + "...");
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
privateKey: keyPair.private,
|
privateKey: keyPair.private,
|
||||||
publicKey: keyPair.public,
|
publicKey: keyPair.public,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("SSH key generation failed:", error);
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error:
|
error:
|
||||||
@@ -785,20 +778,12 @@ router.post(
|
|||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const { privateKey, keyPassword } = req.body;
|
const { privateKey, keyPassword } = req.body;
|
||||||
|
|
||||||
console.log("=== Key Detection API Called ===");
|
|
||||||
console.log("Request body keys:", Object.keys(req.body));
|
|
||||||
console.log("Private key provided:", !!privateKey);
|
|
||||||
console.log("Private key type:", typeof privateKey);
|
|
||||||
|
|
||||||
if (!privateKey || typeof privateKey !== "string") {
|
if (!privateKey || typeof privateKey !== "string") {
|
||||||
console.log("Invalid private key provided");
|
|
||||||
return res.status(400).json({ error: "Private key is required" });
|
return res.status(400).json({ error: "Private key is required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("Calling parseSSHKey...");
|
|
||||||
const keyInfo = parseSSHKey(privateKey, keyPassword);
|
const keyInfo = parseSSHKey(privateKey, keyPassword);
|
||||||
console.log("parseSSHKey result:", keyInfo);
|
|
||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
success: keyInfo.success,
|
success: keyInfo.success,
|
||||||
@@ -808,10 +793,8 @@ router.post(
|
|||||||
error: keyInfo.error || null,
|
error: keyInfo.error || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("Sending response:", response);
|
|
||||||
res.json(response);
|
res.json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Exception in detect-key-type endpoint:", error);
|
|
||||||
authLogger.error("Failed to detect key type", error);
|
authLogger.error("Failed to detect key type", error);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error:
|
error:
|
||||||
@@ -829,20 +812,12 @@ router.post(
|
|||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const { publicKey } = req.body;
|
const { publicKey } = req.body;
|
||||||
|
|
||||||
console.log("=== Public Key Detection API Called ===");
|
|
||||||
console.log("Request body keys:", Object.keys(req.body));
|
|
||||||
console.log("Public key provided:", !!publicKey);
|
|
||||||
console.log("Public key type:", typeof publicKey);
|
|
||||||
|
|
||||||
if (!publicKey || typeof publicKey !== "string") {
|
if (!publicKey || typeof publicKey !== "string") {
|
||||||
console.log("Invalid public key provided");
|
|
||||||
return res.status(400).json({ error: "Public key is required" });
|
return res.status(400).json({ error: "Public key is required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("Calling parsePublicKey...");
|
|
||||||
const keyInfo = parsePublicKey(publicKey);
|
const keyInfo = parsePublicKey(publicKey);
|
||||||
console.log("parsePublicKey result:", keyInfo);
|
|
||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
success: keyInfo.success,
|
success: keyInfo.success,
|
||||||
@@ -851,10 +826,8 @@ router.post(
|
|||||||
error: keyInfo.error || null,
|
error: keyInfo.error || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("Sending response:", response);
|
|
||||||
res.json(response);
|
res.json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Exception in detect-public-key-type endpoint:", error);
|
|
||||||
authLogger.error("Failed to detect public key type", error);
|
authLogger.error("Failed to detect public key type", error);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error:
|
error:
|
||||||
@@ -874,29 +847,20 @@ router.post(
|
|||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const { privateKey, publicKey, keyPassword } = req.body;
|
const { privateKey, publicKey, keyPassword } = req.body;
|
||||||
|
|
||||||
console.log("=== Key Pair Validation API Called ===");
|
|
||||||
console.log("Request body keys:", Object.keys(req.body));
|
|
||||||
console.log("Private key provided:", !!privateKey);
|
|
||||||
console.log("Public key provided:", !!publicKey);
|
|
||||||
|
|
||||||
if (!privateKey || typeof privateKey !== "string") {
|
if (!privateKey || typeof privateKey !== "string") {
|
||||||
console.log("Invalid private key provided");
|
|
||||||
return res.status(400).json({ error: "Private key is required" });
|
return res.status(400).json({ error: "Private key is required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!publicKey || typeof publicKey !== "string") {
|
if (!publicKey || typeof publicKey !== "string") {
|
||||||
console.log("Invalid public key provided");
|
|
||||||
return res.status(400).json({ error: "Public key is required" });
|
return res.status(400).json({ error: "Public key is required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("Calling validateKeyPair...");
|
|
||||||
const validationResult = validateKeyPair(
|
const validationResult = validateKeyPair(
|
||||||
privateKey,
|
privateKey,
|
||||||
publicKey,
|
publicKey,
|
||||||
keyPassword,
|
keyPassword,
|
||||||
);
|
);
|
||||||
console.log("validateKeyPair result:", validationResult);
|
|
||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
isValid: validationResult.isValid,
|
isValid: validationResult.isValid,
|
||||||
@@ -906,10 +870,8 @@ router.post(
|
|||||||
error: validationResult.error || null,
|
error: validationResult.error || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("Sending response:", response);
|
|
||||||
res.json(response);
|
res.json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Exception in validate-key-pair endpoint:", error);
|
|
||||||
authLogger.error("Failed to validate key pair", error);
|
authLogger.error("Failed to validate key pair", error);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error:
|
error:
|
||||||
@@ -929,11 +891,6 @@ router.post(
|
|||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const { keyType = "ssh-ed25519", keySize = 2048, passphrase } = req.body;
|
const { keyType = "ssh-ed25519", keySize = 2048, passphrase } = req.body;
|
||||||
|
|
||||||
console.log("=== Generate Key Pair API Called ===");
|
|
||||||
console.log("Key type:", keyType);
|
|
||||||
console.log("Key size:", keySize);
|
|
||||||
console.log("Has passphrase:", !!passphrase);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Generate SSH keys directly with ssh2
|
// Generate SSH keys directly with ssh2
|
||||||
const result = generateSSHKeyPair(keyType, keySize, passphrase);
|
const result = generateSSHKeyPair(keyType, keySize, passphrase);
|
||||||
@@ -950,17 +907,14 @@ router.post(
|
|||||||
curve: keyType === "ecdsa-sha2-nistp256" ? "nistp256" : undefined,
|
curve: keyType === "ecdsa-sha2-nistp256" ? "nistp256" : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("SSH key pair generated successfully:", keyType);
|
|
||||||
res.json(response);
|
res.json(response);
|
||||||
} else {
|
} else {
|
||||||
console.error("SSH key generation failed:", result.error);
|
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: result.error || "Failed to generate SSH key pair",
|
error: result.error || "Failed to generate SSH key pair",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Exception in generate-key-pair endpoint:", error);
|
|
||||||
authLogger.error("Failed to generate key pair", error);
|
authLogger.error("Failed to generate key pair", error);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -981,23 +935,11 @@ router.post(
|
|||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const { privateKey, keyPassword } = req.body;
|
const { privateKey, keyPassword } = req.body;
|
||||||
|
|
||||||
console.log("=== Generate Public Key API Called ===");
|
|
||||||
console.log("Request body keys:", Object.keys(req.body));
|
|
||||||
console.log("Private key provided:", !!privateKey);
|
|
||||||
console.log("Private key type:", typeof privateKey);
|
|
||||||
|
|
||||||
if (!privateKey || typeof privateKey !== "string") {
|
if (!privateKey || typeof privateKey !== "string") {
|
||||||
console.log("Invalid private key provided");
|
|
||||||
return res.status(400).json({ error: "Private key is required" });
|
return res.status(400).json({ error: "Private key is required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(
|
|
||||||
"Using Node.js crypto to generate public key from private key...",
|
|
||||||
);
|
|
||||||
console.log("Private key length:", privateKey.length);
|
|
||||||
console.log("Private key first 100 chars:", privateKey.substring(0, 100));
|
|
||||||
|
|
||||||
// First try to create private key object from the input
|
// First try to create private key object from the input
|
||||||
let privateKeyObj;
|
let privateKeyObj;
|
||||||
let parseAttempts = [];
|
let parseAttempts = [];
|
||||||
@@ -1008,7 +950,6 @@ router.post(
|
|||||||
key: privateKey,
|
key: privateKey,
|
||||||
passphrase: keyPassword,
|
passphrase: keyPassword,
|
||||||
});
|
});
|
||||||
console.log("Successfully parsed with passphrase method");
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
parseAttempts.push(`Method 1 (with passphrase): ${error.message}`);
|
parseAttempts.push(`Method 1 (with passphrase): ${error.message}`);
|
||||||
}
|
}
|
||||||
@@ -1017,7 +958,6 @@ router.post(
|
|||||||
if (!privateKeyObj) {
|
if (!privateKeyObj) {
|
||||||
try {
|
try {
|
||||||
privateKeyObj = crypto.createPrivateKey(privateKey);
|
privateKeyObj = crypto.createPrivateKey(privateKey);
|
||||||
console.log("Successfully parsed without passphrase");
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
parseAttempts.push(`Method 2 (without passphrase): ${error.message}`);
|
parseAttempts.push(`Method 2 (without passphrase): ${error.message}`);
|
||||||
}
|
}
|
||||||
@@ -1031,7 +971,6 @@ router.post(
|
|||||||
format: "pem",
|
format: "pem",
|
||||||
type: "pkcs8",
|
type: "pkcs8",
|
||||||
});
|
});
|
||||||
console.log("Successfully parsed as PKCS#8");
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
parseAttempts.push(`Method 3 (PKCS#8): ${error.message}`);
|
parseAttempts.push(`Method 3 (PKCS#8): ${error.message}`);
|
||||||
}
|
}
|
||||||
@@ -1048,7 +987,6 @@ router.post(
|
|||||||
format: "pem",
|
format: "pem",
|
||||||
type: "pkcs1",
|
type: "pkcs1",
|
||||||
});
|
});
|
||||||
console.log("Successfully parsed as PKCS#1 RSA");
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
parseAttempts.push(`Method 4 (PKCS#1): ${error.message}`);
|
parseAttempts.push(`Method 4 (PKCS#1): ${error.message}`);
|
||||||
}
|
}
|
||||||
@@ -1065,7 +1003,6 @@ router.post(
|
|||||||
format: "pem",
|
format: "pem",
|
||||||
type: "sec1",
|
type: "sec1",
|
||||||
});
|
});
|
||||||
console.log("Successfully parsed as SEC1 EC");
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
parseAttempts.push(`Method 5 (SEC1): ${error.message}`);
|
parseAttempts.push(`Method 5 (SEC1): ${error.message}`);
|
||||||
}
|
}
|
||||||
@@ -1073,23 +1010,11 @@ router.post(
|
|||||||
|
|
||||||
// Final attempt: Try using ssh2 as fallback
|
// Final attempt: Try using ssh2 as fallback
|
||||||
if (!privateKeyObj) {
|
if (!privateKeyObj) {
|
||||||
console.log("Attempting fallback to parseSSHKey function...");
|
|
||||||
try {
|
try {
|
||||||
const keyInfo = parseSSHKey(privateKey, keyPassword);
|
const keyInfo = parseSSHKey(privateKey, keyPassword);
|
||||||
console.log("parseSSHKey fallback result:", keyInfo);
|
|
||||||
|
|
||||||
if (keyInfo.success && keyInfo.publicKey) {
|
if (keyInfo.success && keyInfo.publicKey) {
|
||||||
// Ensure SSH2 fallback also returns proper string
|
|
||||||
const publicKeyString = String(keyInfo.publicKey);
|
const publicKeyString = String(keyInfo.publicKey);
|
||||||
console.log(
|
|
||||||
"SSH2 fallback public key type:",
|
|
||||||
typeof publicKeyString,
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
"SSH2 fallback public key length:",
|
|
||||||
publicKeyString.length,
|
|
||||||
);
|
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
publicKey: publicKeyString,
|
publicKey: publicKeyString,
|
||||||
@@ -1106,7 +1031,6 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!privateKeyObj) {
|
if (!privateKeyObj) {
|
||||||
console.error("All parsing attempts failed:", parseAttempts);
|
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: "Unable to parse private key. Tried multiple formats.",
|
error: "Unable to parse private key. Tried multiple formats.",
|
||||||
@@ -1121,30 +1045,12 @@ router.post(
|
|||||||
format: "pem",
|
format: "pem",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Debug: Check what we're actually generating
|
|
||||||
console.log("Generated public key type:", typeof publicKeyPem);
|
|
||||||
console.log(
|
|
||||||
"Generated public key is Buffer:",
|
|
||||||
Buffer.isBuffer(publicKeyPem),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Ensure publicKeyPem is a string
|
// Ensure publicKeyPem is a string
|
||||||
const publicKeyString =
|
const publicKeyString =
|
||||||
typeof publicKeyPem === "string"
|
typeof publicKeyPem === "string"
|
||||||
? publicKeyPem
|
? publicKeyPem
|
||||||
: publicKeyPem.toString("utf8");
|
: publicKeyPem.toString("utf8");
|
||||||
|
|
||||||
console.log("Public key string length:", publicKeyString.length);
|
|
||||||
console.log(
|
|
||||||
"Generated public key first 100 chars:",
|
|
||||||
publicKeyString.substring(0, 100),
|
|
||||||
);
|
|
||||||
console.log("Public key is string:", typeof publicKeyString === "string");
|
|
||||||
console.log(
|
|
||||||
"Public key contains PEM header:",
|
|
||||||
publicKeyString.includes("-----BEGIN PUBLIC KEY-----"),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Detect key type from the private key object
|
// Detect key type from the private key object
|
||||||
let keyType = "unknown";
|
let keyType = "unknown";
|
||||||
const asymmetricKeyType = privateKeyObj.asymmetricKeyType;
|
const asymmetricKeyType = privateKeyObj.asymmetricKeyType;
|
||||||
@@ -1169,12 +1075,9 @@ router.post(
|
|||||||
const base64Data = publicKeyBuffer.toString("base64");
|
const base64Data = publicKeyBuffer.toString("base64");
|
||||||
finalPublicKey = `${keyType} ${base64Data}`;
|
finalPublicKey = `${keyType} ${base64Data}`;
|
||||||
formatType = "ssh";
|
formatType = "ssh";
|
||||||
console.log("SSH format public key generated!");
|
|
||||||
} else {
|
|
||||||
console.warn("ssh2 parsing failed, using PEM format");
|
|
||||||
}
|
}
|
||||||
} catch (sshError) {
|
} catch (sshError) {
|
||||||
console.warn("ssh2 failed, using PEM format");
|
// Use PEM format as fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
@@ -1184,20 +1087,8 @@ router.post(
|
|||||||
format: formatType,
|
format: formatType,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("Final response publicKey type:", typeof response.publicKey);
|
|
||||||
console.log("Final response publicKey format:", response.format);
|
|
||||||
console.log(
|
|
||||||
"Final response publicKey length:",
|
|
||||||
response.publicKey.length,
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
"Public key generated successfully using crypto module:",
|
|
||||||
keyType,
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json(response);
|
res.json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Exception in generate-public-key endpoint:", error);
|
|
||||||
authLogger.error("Failed to generate public key", error);
|
authLogger.error("Failed to generate public key", error);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
@@ -74,21 +74,6 @@ router.get("/db/host/internal", async (req: Request, res: Response) => {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log("=== AUTOSTART QUERY DEBUG ===");
|
|
||||||
console.log("Found autostart hosts count:", autostartHosts.length);
|
|
||||||
autostartHosts.forEach((host, index) => {
|
|
||||||
console.log(`Host ${index + 1}:`, {
|
|
||||||
id: host.id,
|
|
||||||
ip: host.ip,
|
|
||||||
username: host.username,
|
|
||||||
hasAutostartPassword: !!host.autostartPassword,
|
|
||||||
hasAutostartKey: !!host.autostartKey,
|
|
||||||
autostartPasswordLength: host.autostartPassword?.length || 0,
|
|
||||||
autostartKeyLength: host.autostartKey?.length || 0
|
|
||||||
});
|
|
||||||
});
|
|
||||||
console.log("=== END AUTOSTART QUERY DEBUG ===");
|
|
||||||
|
|
||||||
sshLogger.info("Internal autostart endpoint accessed", {
|
sshLogger.info("Internal autostart endpoint accessed", {
|
||||||
operation: "autostart_internal_access",
|
operation: "autostart_internal_access",
|
||||||
configCount: autostartHosts.length,
|
configCount: autostartHosts.length,
|
||||||
@@ -102,20 +87,6 @@ router.get("/db/host/internal", async (req: Request, res: Response) => {
|
|||||||
? JSON.parse(host.tunnelConnections)
|
? JSON.parse(host.tunnelConnections)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
// Debug: Log what we're reading from database
|
|
||||||
sshLogger.info(`Autostart host from DB:`, {
|
|
||||||
hostId: host.id,
|
|
||||||
ip: host.ip,
|
|
||||||
username: host.username,
|
|
||||||
hasAutostartPassword: !!host.autostartPassword,
|
|
||||||
hasAutostartKey: !!host.autostartKey,
|
|
||||||
hasEncryptedPassword: !!host.password,
|
|
||||||
hasEncryptedKey: !!host.key,
|
|
||||||
authType: host.authType,
|
|
||||||
autostartPasswordLength: host.autostartPassword?.length || 0,
|
|
||||||
autostartKeyLength: host.autostartKey?.length || 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: host.id,
|
id: host.id,
|
||||||
userId: host.userId,
|
userId: host.userId,
|
||||||
@@ -179,22 +150,6 @@ router.get("/db/host/internal/all", async (req: Request, res: Response) => {
|
|||||||
? JSON.parse(host.tunnelConnections)
|
? JSON.parse(host.tunnelConnections)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
// Debug: Log what we're reading from database for all hosts
|
|
||||||
sshLogger.info(`All hosts endpoint - host from DB:`, {
|
|
||||||
hostId: host.id,
|
|
||||||
ip: host.ip,
|
|
||||||
username: host.username,
|
|
||||||
hasAutostartPassword: !!host.autostartPassword,
|
|
||||||
hasAutostartKey: !!host.autostartKey,
|
|
||||||
hasEncryptedPassword: !!host.password,
|
|
||||||
hasEncryptedKey: !!host.key,
|
|
||||||
authType: host.authType,
|
|
||||||
autostartPasswordLength: host.autostartPassword?.length || 0,
|
|
||||||
autostartKeyLength: host.autostartKey?.length || 0,
|
|
||||||
encryptedPasswordLength: host.password?.length || 0,
|
|
||||||
encryptedKeyLength: host.key?.length || 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: host.id,
|
id: host.id,
|
||||||
userId: host.userId,
|
userId: host.userId,
|
||||||
@@ -1474,17 +1429,6 @@ router.post(
|
|||||||
// Decrypt sensitive fields
|
// Decrypt sensitive fields
|
||||||
const decryptedConfig = DataCrypto.decryptRecord("ssh_data", config, userId, userDataKey);
|
const decryptedConfig = DataCrypto.decryptRecord("ssh_data", config, userId, userDataKey);
|
||||||
|
|
||||||
// Debug: Log what we're about to save
|
|
||||||
console.log("=== AUTOSTART DEBUG: Decrypted credentials ===");
|
|
||||||
console.log("sshConfigId:", sshConfigId);
|
|
||||||
console.log("authType:", config.authType);
|
|
||||||
console.log("hasPassword:", !!decryptedConfig.password);
|
|
||||||
console.log("hasKey:", !!decryptedConfig.key);
|
|
||||||
console.log("hasKeyPassword:", !!decryptedConfig.keyPassword);
|
|
||||||
console.log("passwordLength:", decryptedConfig.password?.length || 0);
|
|
||||||
console.log("keyLength:", decryptedConfig.key?.length || 0);
|
|
||||||
console.log("=== END AUTOSTART DEBUG ===");
|
|
||||||
|
|
||||||
// Also handle tunnel connections - populate endpoint credentials
|
// Also handle tunnel connections - populate endpoint credentials
|
||||||
let updatedTunnelConnections = config.tunnelConnections;
|
let updatedTunnelConnections = config.tunnelConnections;
|
||||||
if (config.tunnelConnections) {
|
if (config.tunnelConnections) {
|
||||||
@@ -1495,9 +1439,6 @@ router.post(
|
|||||||
const resolvedConnections = await Promise.all(
|
const resolvedConnections = await Promise.all(
|
||||||
tunnelConnections.map(async (tunnel: any) => {
|
tunnelConnections.map(async (tunnel: any) => {
|
||||||
if (tunnel.autoStart && tunnel.endpointHost && !tunnel.endpointPassword && !tunnel.endpointKey) {
|
if (tunnel.autoStart && tunnel.endpointHost && !tunnel.endpointPassword && !tunnel.endpointKey) {
|
||||||
console.log("=== RESOLVING ENDPOINT CREDENTIALS ===");
|
|
||||||
console.log("endpointHost:", tunnel.endpointHost);
|
|
||||||
|
|
||||||
// Find endpoint host by name or username@ip
|
// Find endpoint host by name or username@ip
|
||||||
const endpointHosts = await db.select()
|
const endpointHosts = await db.select()
|
||||||
.from(sshData)
|
.from(sshData)
|
||||||
@@ -1509,17 +1450,9 @@ router.post(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (endpointHost) {
|
if (endpointHost) {
|
||||||
console.log("Found endpoint host:", endpointHost.id, endpointHost.ip);
|
|
||||||
|
|
||||||
// Decrypt endpoint host credentials
|
// Decrypt endpoint host credentials
|
||||||
const decryptedEndpoint = DataCrypto.decryptRecord("ssh_data", endpointHost, userId, userDataKey);
|
const decryptedEndpoint = DataCrypto.decryptRecord("ssh_data", endpointHost, userId, userDataKey);
|
||||||
|
|
||||||
console.log("Endpoint credentials:", {
|
|
||||||
hasPassword: !!decryptedEndpoint.password,
|
|
||||||
hasKey: !!decryptedEndpoint.key,
|
|
||||||
passwordLength: decryptedEndpoint.password?.length || 0
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add endpoint credentials to tunnel connection
|
// Add endpoint credentials to tunnel connection
|
||||||
return {
|
return {
|
||||||
...tunnel,
|
...tunnel,
|
||||||
@@ -1535,9 +1468,11 @@ router.post(
|
|||||||
);
|
);
|
||||||
|
|
||||||
updatedTunnelConnections = JSON.stringify(resolvedConnections);
|
updatedTunnelConnections = JSON.stringify(resolvedConnections);
|
||||||
console.log("=== UPDATED TUNNEL CONNECTIONS ===");
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("=== TUNNEL CONNECTION UPDATE FAILED ===", error);
|
sshLogger.warn("Failed to update tunnel connections", {
|
||||||
|
operation: "tunnel_connections_update_failed",
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1551,36 +1486,14 @@ router.post(
|
|||||||
})
|
})
|
||||||
.where(eq(sshData.id, sshConfigId));
|
.where(eq(sshData.id, sshConfigId));
|
||||||
|
|
||||||
// Debug: Log update result
|
|
||||||
console.log("=== AUTOSTART DEBUG: Update result ===");
|
|
||||||
console.log("updateResult:", updateResult);
|
|
||||||
console.log("update completed for sshConfigId:", sshConfigId);
|
|
||||||
console.log("=== END UPDATE DEBUG ===");
|
|
||||||
|
|
||||||
// Force database save after autostart update
|
// Force database save after autostart update
|
||||||
try {
|
try {
|
||||||
await DatabaseSaveTrigger.triggerSave();
|
await DatabaseSaveTrigger.triggerSave();
|
||||||
console.log("=== DATABASE SAVE TRIGGERED AFTER AUTOSTART ===");
|
|
||||||
} catch (saveError) {
|
} catch (saveError) {
|
||||||
console.log("=== DATABASE SAVE FAILED ===", saveError);
|
sshLogger.warn("Database save failed after autostart", {
|
||||||
}
|
operation: "autostart_db_save_failed",
|
||||||
|
error: saveError instanceof Error ? saveError.message : "Unknown error"
|
||||||
// Verify the data was actually saved
|
});
|
||||||
try {
|
|
||||||
const verifyQuery = await db.select()
|
|
||||||
.from(sshData)
|
|
||||||
.where(eq(sshData.id, sshConfigId));
|
|
||||||
|
|
||||||
if (verifyQuery.length > 0) {
|
|
||||||
const saved = verifyQuery[0];
|
|
||||||
console.log("=== VERIFICATION: Data actually saved ===");
|
|
||||||
console.log("autostartPassword exists:", !!saved.autostartPassword);
|
|
||||||
console.log("autostartKey exists:", !!saved.autostartKey);
|
|
||||||
console.log("autostartPassword length:", saved.autostartPassword?.length || 0);
|
|
||||||
console.log("=== END VERIFICATION ===");
|
|
||||||
}
|
|
||||||
} catch (verifyError) {
|
|
||||||
console.log("=== VERIFICATION FAILED ===", verifyError);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sshLogger.success("AutoStart enabled successfully", {
|
sshLogger.success("AutoStart enabled successfully", {
|
||||||
|
|||||||
@@ -505,7 +505,7 @@ router.get("/oidc/authorize", async (req, res) => {
|
|||||||
"http://localhost:5173";
|
"http://localhost:5173";
|
||||||
|
|
||||||
if (origin.includes("localhost")) {
|
if (origin.includes("localhost")) {
|
||||||
origin = "http://localhost:8081";
|
origin = "http://localhost:30001";
|
||||||
}
|
}
|
||||||
|
|
||||||
const redirectUri = `${origin}/users/oidc/callback`;
|
const redirectUri = `${origin}/users/oidc/callback`;
|
||||||
|
|||||||
@@ -95,7 +95,6 @@ function scheduleSessionCleanup(sessionId: string) {
|
|||||||
|
|
||||||
// Increase timeout to 30 minutes of inactivity
|
// Increase timeout to 30 minutes of inactivity
|
||||||
session.timeout = setTimeout(() => {
|
session.timeout = setTimeout(() => {
|
||||||
fileLogger.info(`Cleaning up inactive SSH session: ${sessionId}`);
|
|
||||||
cleanupSession(sessionId);
|
cleanupSession(sessionId);
|
||||||
}, 30 * 60 * 1000); // 30 minutes - increased from 10 minutes
|
}, 30 * 60 * 1000); // 30 minutes - increased from 10 minutes
|
||||||
}
|
}
|
||||||
@@ -342,12 +341,6 @@ app.post("/ssh/file_manager/ssh/keepalive", (req, res) => {
|
|||||||
session.lastActive = Date.now();
|
session.lastActive = Date.now();
|
||||||
scheduleSessionCleanup(sessionId);
|
scheduleSessionCleanup(sessionId);
|
||||||
|
|
||||||
fileLogger.debug(`SSH session keepalive: ${sessionId}`, {
|
|
||||||
operation: "ssh_keepalive",
|
|
||||||
sessionId,
|
|
||||||
lastActive: session.lastActive,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
status: "success",
|
status: "success",
|
||||||
connected: true,
|
connected: true,
|
||||||
@@ -2124,7 +2117,7 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const PORT = 8084;
|
const PORT = 30004;
|
||||||
app.listen(PORT, async () => {
|
app.listen(PORT, async () => {
|
||||||
fileLogger.success("File Manager API server started", {
|
fileLogger.success("File Manager API server started", {
|
||||||
operation: "server_start",
|
operation: "server_start",
|
||||||
|
|||||||
@@ -412,19 +412,6 @@ async function resolveHostCredentials(
|
|||||||
|
|
||||||
if (credentials.length > 0) {
|
if (credentials.length > 0) {
|
||||||
const credential = credentials[0];
|
const credential = credentials[0];
|
||||||
statsLogger.debug(
|
|
||||||
`Using credential ${credential.id} for host ${host.id}`,
|
|
||||||
{
|
|
||||||
operation: "credential_resolve",
|
|
||||||
credentialId: credential.id,
|
|
||||||
authType: credential.authType,
|
|
||||||
hasPassword: !!credential.password,
|
|
||||||
hasKey: !!credential.key,
|
|
||||||
passwordLength: credential.password?.length || 0,
|
|
||||||
keyLength: credential.key?.length || 0,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
baseHost.credentialId = credential.id;
|
baseHost.credentialId = credential.id;
|
||||||
baseHost.username = credential.username;
|
baseHost.username = credential.username;
|
||||||
baseHost.authType = credential.authType;
|
baseHost.authType = credential.authType;
|
||||||
@@ -471,20 +458,6 @@ function addLegacyCredentials(baseHost: any, host: any): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
|
function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
|
||||||
statsLogger.debug(`Building SSH config for host ${host.ip}`, {
|
|
||||||
operation: "ssh_config",
|
|
||||||
authType: host.authType,
|
|
||||||
hasPassword: !!host.password,
|
|
||||||
hasKey: !!host.key,
|
|
||||||
username: host.username,
|
|
||||||
passwordLength: host.password?.length || 0,
|
|
||||||
keyLength: host.key?.length || 0,
|
|
||||||
passwordType: typeof host.password,
|
|
||||||
passwordRaw: host.password
|
|
||||||
? JSON.stringify(host.password.substring(0, 20))
|
|
||||||
: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const base: ConnectConfig = {
|
const base: ConnectConfig = {
|
||||||
host: host.ip,
|
host: host.ip,
|
||||||
port: host.port || 22,
|
port: host.port || 22,
|
||||||
@@ -521,26 +494,12 @@ function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
|
|||||||
if (!host.password) {
|
if (!host.password) {
|
||||||
throw new Error(`No password available for host ${host.ip}`);
|
throw new Error(`No password available for host ${host.ip}`);
|
||||||
}
|
}
|
||||||
statsLogger.debug(`Using password auth for ${host.ip}`, {
|
|
||||||
operation: "ssh_config",
|
|
||||||
passwordLength: host.password.length,
|
|
||||||
passwordFirst3: host.password.substring(0, 3),
|
|
||||||
passwordLast3: host.password.substring(host.password.length - 3),
|
|
||||||
passwordType: typeof host.password,
|
|
||||||
passwordIsString: typeof host.password === "string",
|
|
||||||
});
|
|
||||||
(base as any).password = host.password;
|
(base as any).password = host.password;
|
||||||
} else if (host.authType === "key") {
|
} else if (host.authType === "key") {
|
||||||
if (!host.key) {
|
if (!host.key) {
|
||||||
throw new Error(`No SSH key available for host ${host.ip}`);
|
throw new Error(`No SSH key available for host ${host.ip}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
statsLogger.debug(`Using key auth for ${host.ip}`, {
|
|
||||||
operation: "ssh_config",
|
|
||||||
keyPreview: host.key.substring(0, Math.min(50, host.key.length)) + "...",
|
|
||||||
hasPassphrase: !!host.keyPassword,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!host.key.includes("-----BEGIN") || !host.key.includes("-----END")) {
|
if (!host.key.includes("-----BEGIN") || !host.key.includes("-----END")) {
|
||||||
throw new Error("Invalid private key format");
|
throw new Error("Invalid private key format");
|
||||||
@@ -988,7 +947,7 @@ process.on("SIGTERM", () => {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
const PORT = 8085;
|
const PORT = 30005;
|
||||||
app.listen(PORT, async () => {
|
app.listen(PORT, async () => {
|
||||||
statsLogger.success("Server Stats API server started", {
|
statsLogger.success("Server Stats API server started", {
|
||||||
operation: "server_start",
|
operation: "server_start",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const userCrypto = UserCrypto.getInstance();
|
|||||||
const userConnections = new Map<string, Set<WebSocket>>();
|
const userConnections = new Map<string, Set<WebSocket>>();
|
||||||
|
|
||||||
const wss = new WebSocketServer({
|
const wss = new WebSocketServer({
|
||||||
port: 8082,
|
port: 30002,
|
||||||
// WebSocket authentication during handshake
|
// WebSocket authentication during handshake
|
||||||
verifyClient: async (info) => {
|
verifyClient: async (info) => {
|
||||||
try {
|
try {
|
||||||
@@ -90,7 +90,7 @@ const wss = new WebSocketServer({
|
|||||||
|
|
||||||
sshLogger.success("SSH Terminal WebSocket server started with authentication", {
|
sshLogger.success("SSH Terminal WebSocket server started with authentication", {
|
||||||
operation: "server_start",
|
operation: "server_start",
|
||||||
port: 8082,
|
port: 30002,
|
||||||
features: ["JWT_auth", "connection_limits", "data_access_control"]
|
features: ["JWT_auth", "connection_limits", "data_access_control"]
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -369,26 +369,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
}
|
}
|
||||||
}, 60000);
|
}, 60000);
|
||||||
|
|
||||||
sshLogger.debug(`Terminal SSH setup`, {
|
|
||||||
operation: "terminal_ssh",
|
|
||||||
hostId: id,
|
|
||||||
ip,
|
|
||||||
authType,
|
|
||||||
hasPassword: !!password,
|
|
||||||
passwordLength: password?.length || 0,
|
|
||||||
hasCredentialId: !!credentialId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// SECURITY: Never log password information - removed password preview logging
|
|
||||||
sshLogger.debug(`SSH authentication setup`, {
|
|
||||||
operation: "terminal_ssh_auth_setup",
|
|
||||||
userId,
|
|
||||||
hostId: id,
|
|
||||||
authType,
|
|
||||||
hasPassword: !!password,
|
|
||||||
hasCredentialId: !!credentialId,
|
|
||||||
});
|
|
||||||
|
|
||||||
let resolvedCredentials = { password, key, keyPassword, keyType, authType };
|
let resolvedCredentials = { password, key, keyPassword, keyType, authType };
|
||||||
if (credentialId && id && hostConfig.userId) {
|
if (credentialId && id && hostConfig.userId) {
|
||||||
try {
|
try {
|
||||||
@@ -502,12 +482,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
|
|
||||||
// Change to initial path if specified
|
// Change to initial path if specified
|
||||||
if (initialPath && initialPath.trim() !== "") {
|
if (initialPath && initialPath.trim() !== "") {
|
||||||
sshLogger.debug(`Changing to initial path: ${initialPath}`, {
|
|
||||||
operation: "ssh_initial_path",
|
|
||||||
hostId: id,
|
|
||||||
path: initialPath,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send cd command to change directory
|
// Send cd command to change directory
|
||||||
const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}" && pwd\n`;
|
const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}" && pwd\n`;
|
||||||
stream.write(cdCommand);
|
stream.write(cdCommand);
|
||||||
@@ -515,12 +489,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
|
|
||||||
// Execute command if specified
|
// Execute command if specified
|
||||||
if (executeCommand && executeCommand.trim() !== "") {
|
if (executeCommand && executeCommand.trim() !== "") {
|
||||||
sshLogger.debug(`Executing command: ${executeCommand}`, {
|
|
||||||
operation: "ssh_execute_command",
|
|
||||||
hostId: id,
|
|
||||||
command: executeCommand,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait a moment for the cd command to complete, then execute the command
|
// Wait a moment for the cd command to complete, then execute the command
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const command = `${executeCommand}\n`;
|
const command = `${executeCommand}\n`;
|
||||||
|
|||||||
@@ -1283,7 +1283,7 @@ async function initializeAutoStartTunnels(): Promise<void> {
|
|||||||
|
|
||||||
// Get autostart hosts for tunnel configs
|
// Get autostart hosts for tunnel configs
|
||||||
const autostartResponse = await axios.get(
|
const autostartResponse = await axios.get(
|
||||||
"http://localhost:8081/ssh/db/host/internal",
|
"http://localhost:30001/ssh/db/host/internal",
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -1294,7 +1294,7 @@ async function initializeAutoStartTunnels(): Promise<void> {
|
|||||||
|
|
||||||
// Get all hosts for endpointHost resolution
|
// Get all hosts for endpointHost resolution
|
||||||
const allHostsResponse = await axios.get(
|
const allHostsResponse = await axios.get(
|
||||||
"http://localhost:8081/ssh/db/host/internal/all",
|
"http://localhost:30001/ssh/db/host/internal/all",
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -1420,7 +1420,7 @@ async function initializeAutoStartTunnels(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const PORT = 8083;
|
const PORT = 30003;
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
tunnelLogger.success("SSH Tunnel API server started", {
|
tunnelLogger.success("SSH Tunnel API server started", {
|
||||||
operation: "server_start",
|
operation: "server_start",
|
||||||
|
|||||||
@@ -121,10 +121,6 @@ class AuthManager {
|
|||||||
migratedFieldsCount: migrationResult.migratedFieldsCount,
|
migratedFieldsCount: migrationResult.migratedFieldsCount,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
databaseLogger.debug("No lazy encryption migration needed for user", {
|
|
||||||
operation: "lazy_encryption_migration_not_needed",
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -24,13 +24,13 @@ export class AutoSSLSetup {
|
|||||||
*/
|
*/
|
||||||
static async initialize(): Promise<void> {
|
static async initialize(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
systemLogger.info("🔐 Initializing SSL/TLS configuration...", {
|
systemLogger.info("Initializing SSL/TLS configuration...", {
|
||||||
operation: "ssl_auto_init"
|
operation: "ssl_auto_init"
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if SSL is already properly configured
|
// Check if SSL is already properly configured
|
||||||
if (await this.isSSLConfigured()) {
|
if (await this.isSSLConfigured()) {
|
||||||
systemLogger.info("✅ SSL configuration already exists and is valid", {
|
systemLogger.info("SSL configuration already exists and is valid", {
|
||||||
operation: "ssl_already_configured"
|
operation: "ssl_already_configured"
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -42,19 +42,19 @@ export class AutoSSLSetup {
|
|||||||
// Setup environment variables for SSL
|
// Setup environment variables for SSL
|
||||||
await this.setupEnvironmentVariables();
|
await this.setupEnvironmentVariables();
|
||||||
|
|
||||||
systemLogger.success("🚀 SSL/TLS configuration completed successfully", {
|
systemLogger.success("SSL/TLS configuration completed successfully", {
|
||||||
operation: "ssl_auto_init_complete",
|
operation: "ssl_auto_init_complete",
|
||||||
https_port: process.env.SSL_PORT || "8443",
|
https_port: process.env.SSL_PORT || "8443",
|
||||||
note: "HTTPS/WSS is now enabled by default"
|
note: "HTTPS/WSS is now enabled by default"
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
systemLogger.error("❌ Failed to initialize SSL configuration", error, {
|
systemLogger.error("Failed to initialize SSL configuration", error, {
|
||||||
operation: "ssl_auto_init_failed"
|
operation: "ssl_auto_init_failed"
|
||||||
});
|
});
|
||||||
|
|
||||||
// Don't crash the application - fallback to HTTP
|
// Don't crash the application - fallback to HTTP
|
||||||
systemLogger.warn("⚠️ Falling back to HTTP-only mode", {
|
systemLogger.warn("Falling back to HTTP-only mode", {
|
||||||
operation: "ssl_fallback_http"
|
operation: "ssl_fallback_http"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -84,7 +84,7 @@ export class AutoSSLSetup {
|
|||||||
* Generate SSL certificates automatically
|
* Generate SSL certificates automatically
|
||||||
*/
|
*/
|
||||||
private static async generateSSLCertificates(): Promise<void> {
|
private static async generateSSLCertificates(): Promise<void> {
|
||||||
systemLogger.info("🔑 Generating SSL certificates for local development...", {
|
systemLogger.info("Generating SSL certificates for local development...", {
|
||||||
operation: "ssl_cert_generation"
|
operation: "ssl_cert_generation"
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -142,7 +142,7 @@ IP.2 = ::1
|
|||||||
// Clean up temp config
|
// Clean up temp config
|
||||||
await fs.unlink(configFile);
|
await fs.unlink(configFile);
|
||||||
|
|
||||||
systemLogger.success("✅ SSL certificates generated successfully", {
|
systemLogger.success("SSL certificates generated successfully", {
|
||||||
operation: "ssl_cert_generated",
|
operation: "ssl_cert_generated",
|
||||||
cert_path: this.CERT_FILE,
|
cert_path: this.CERT_FILE,
|
||||||
key_path: this.KEY_FILE,
|
key_path: this.KEY_FILE,
|
||||||
@@ -158,7 +158,7 @@ IP.2 = ::1
|
|||||||
* Setup environment variables for SSL configuration
|
* Setup environment variables for SSL configuration
|
||||||
*/
|
*/
|
||||||
private static async setupEnvironmentVariables(): Promise<void> {
|
private static async setupEnvironmentVariables(): Promise<void> {
|
||||||
systemLogger.info("⚙️ Configuring SSL environment variables...", {
|
systemLogger.info("Configuring SSL environment variables...", {
|
||||||
operation: "ssl_env_setup"
|
operation: "ssl_env_setup"
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -207,7 +207,7 @@ IP.2 = ::1
|
|||||||
if (hasChanges || !envContent) {
|
if (hasChanges || !envContent) {
|
||||||
await fs.writeFile(this.ENV_FILE, updatedContent.trim() + '\n');
|
await fs.writeFile(this.ENV_FILE, updatedContent.trim() + '\n');
|
||||||
|
|
||||||
systemLogger.info("✅ SSL environment variables configured", {
|
systemLogger.info("SSL environment variables configured", {
|
||||||
operation: "ssl_env_configured",
|
operation: "ssl_env_configured",
|
||||||
file: this.ENV_FILE,
|
file: this.ENV_FILE,
|
||||||
variables: Object.keys(sslEnvVars)
|
variables: Object.keys(sslEnvVars)
|
||||||
@@ -248,12 +248,12 @@ IP.2 = ::1
|
|||||||
║ HTTP Port: ${(process.env.PORT || "8080").padEnd(47)} ║
|
║ HTTP Port: ${(process.env.PORT || "8080").padEnd(47)} ║
|
||||||
║ Domain: ${config.domain.padEnd(47)} ║
|
║ Domain: ${config.domain.padEnd(47)} ║
|
||||||
║ ║
|
║ ║
|
||||||
║ 🌐 Access URLs: ║
|
║ Access URLs: ║
|
||||||
║ • HTTPS: https://localhost:${config.port.toString().padEnd(31)} ║
|
║ • HTTPS: https://localhost:${config.port.toString().padEnd(31)} ║
|
||||||
║ • HTTP: http://localhost:${(process.env.PORT || "8080").padEnd(32)} ║
|
║ • HTTP: http://localhost:${(process.env.PORT || "8080").padEnd(32)} ║
|
||||||
║ ║
|
║ ║
|
||||||
║ 🔐 WebSocket connections automatically use WSS over HTTPS ║
|
║ WebSocket connections automatically use WSS over HTTPS ║
|
||||||
║ ⚠️ Self-signed certificate will show browser warnings ║
|
║ Self-signed certificate will show browser warnings ║
|
||||||
╚══════════════════════════════════════════════════════════════╝
|
╚══════════════════════════════════════════════════════════════╝
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -440,10 +440,6 @@ class DatabaseFileEncryption {
|
|||||||
for (const tempFile of tempFiles) {
|
for (const tempFile of tempFiles) {
|
||||||
if (fs.existsSync(tempFile)) {
|
if (fs.existsSync(tempFile)) {
|
||||||
fs.unlinkSync(tempFile);
|
fs.unlinkSync(tempFile);
|
||||||
databaseLogger.debug("Cleaned up temporary file", {
|
|
||||||
operation: "temp_cleanup",
|
|
||||||
file: tempFile,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -183,12 +183,6 @@ export class DatabaseMigration {
|
|||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
databaseLogger.debug("Table verification passed", {
|
|
||||||
operation: "migration_verify_table_success",
|
|
||||||
table: table.name,
|
|
||||||
rows: originalCount.count,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
databaseLogger.success("Migration integrity verification completed", {
|
databaseLogger.success("Migration integrity verification completed", {
|
||||||
@@ -253,11 +247,6 @@ export class DatabaseMigration {
|
|||||||
for (const table of tables) {
|
for (const table of tables) {
|
||||||
memoryDb.exec(table.sql);
|
memoryDb.exec(table.sql);
|
||||||
migratedTables++;
|
migratedTables++;
|
||||||
|
|
||||||
databaseLogger.debug("Table structure created", {
|
|
||||||
operation: "migration_table_created",
|
|
||||||
table: table.name,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. 禁用外键约束以避免插入顺序问题
|
// 6. 禁用外键约束以避免插入顺序问题
|
||||||
@@ -287,12 +276,6 @@ export class DatabaseMigration {
|
|||||||
|
|
||||||
insertTransaction(rows);
|
insertTransaction(rows);
|
||||||
migratedRows += rows.length;
|
migratedRows += rows.length;
|
||||||
|
|
||||||
databaseLogger.debug("Table data migrated", {
|
|
||||||
operation: "migration_table_data",
|
|
||||||
table: table.name,
|
|
||||||
rows: rows.length,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,10 +407,6 @@ export class DatabaseMigration {
|
|||||||
for (const file of [...backupsToDelete, ...migratedToDelete]) {
|
for (const file of [...backupsToDelete, ...migratedToDelete]) {
|
||||||
try {
|
try {
|
||||||
fs.unlinkSync(file.path);
|
fs.unlinkSync(file.path);
|
||||||
databaseLogger.debug("Cleaned up old migration file", {
|
|
||||||
operation: "migration_cleanup",
|
|
||||||
file: file.name,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.warn("Failed to cleanup old migration file", {
|
databaseLogger.warn("Failed to cleanup old migration file", {
|
||||||
operation: "migration_cleanup_failed",
|
operation: "migration_cleanup_failed",
|
||||||
|
|||||||
@@ -42,27 +42,13 @@ export class DatabaseSaveTrigger {
|
|||||||
// 防抖:延迟2秒执行,如果2秒内有新的保存请求,则重新计时
|
// 防抖:延迟2秒执行,如果2秒内有新的保存请求,则重新计时
|
||||||
this.saveTimeout = setTimeout(async () => {
|
this.saveTimeout = setTimeout(async () => {
|
||||||
if (this.pendingSave) {
|
if (this.pendingSave) {
|
||||||
databaseLogger.debug("Database save already in progress, skipping", {
|
|
||||||
operation: "db_save_trigger_skip",
|
|
||||||
reason,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.pendingSave = true;
|
this.pendingSave = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
databaseLogger.debug("Triggering database save", {
|
|
||||||
operation: "db_save_trigger_start",
|
|
||||||
reason,
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.saveFunction!();
|
await this.saveFunction!();
|
||||||
|
|
||||||
databaseLogger.debug("Database save completed", {
|
|
||||||
operation: "db_save_trigger_success",
|
|
||||||
reason,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error("Database save failed", error, {
|
databaseLogger.error("Database save failed", error, {
|
||||||
operation: "db_save_trigger_failed",
|
operation: "db_save_trigger_failed",
|
||||||
@@ -94,10 +80,6 @@ export class DatabaseSaveTrigger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.pendingSave) {
|
if (this.pendingSave) {
|
||||||
databaseLogger.debug("Database save already in progress, waiting", {
|
|
||||||
operation: "db_save_trigger_force_wait",
|
|
||||||
reason,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,22 +41,11 @@ export class LazyFieldEncryption {
|
|||||||
|
|
||||||
if (this.isPlaintextField(fieldValue)) {
|
if (this.isPlaintextField(fieldValue)) {
|
||||||
// 明文数据,直接返回
|
// 明文数据,直接返回
|
||||||
databaseLogger.debug("Field detected as plaintext, returning as-is", {
|
|
||||||
operation: "lazy_encryption_plaintext_detected",
|
|
||||||
recordId,
|
|
||||||
fieldName,
|
|
||||||
valuePreview: fieldValue.substring(0, 10) + "...",
|
|
||||||
});
|
|
||||||
return fieldValue;
|
return fieldValue;
|
||||||
} else {
|
} else {
|
||||||
// 加密数据,需要解密
|
// 加密数据,需要解密
|
||||||
try {
|
try {
|
||||||
const decrypted = FieldCrypto.decryptField(fieldValue, userKEK, recordId, fieldName);
|
const decrypted = FieldCrypto.decryptField(fieldValue, userKEK, recordId, fieldName);
|
||||||
databaseLogger.debug("Field decrypted successfully", {
|
|
||||||
operation: "lazy_encryption_decrypt_success",
|
|
||||||
recordId,
|
|
||||||
fieldName,
|
|
||||||
});
|
|
||||||
return decrypted;
|
return decrypted;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error("Failed to decrypt field", error, {
|
databaseLogger.error("Failed to decrypt field", error, {
|
||||||
@@ -108,11 +97,6 @@ export class LazyFieldEncryption {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 已经加密,无需处理
|
// 已经加密,无需处理
|
||||||
databaseLogger.debug("Field already encrypted, no migration needed", {
|
|
||||||
operation: "lazy_encryption_already_encrypted",
|
|
||||||
recordId,
|
|
||||||
fieldName,
|
|
||||||
});
|
|
||||||
return { encrypted: fieldValue, wasPlaintext: false };
|
return { encrypted: fieldValue, wasPlaintext: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,12 +133,6 @@ export class LazyFieldEncryption {
|
|||||||
updatedRecord[fieldName] = encrypted;
|
updatedRecord[fieldName] = encrypted;
|
||||||
migratedFields.push(fieldName);
|
migratedFields.push(fieldName);
|
||||||
needsUpdate = true;
|
needsUpdate = true;
|
||||||
|
|
||||||
databaseLogger.debug("Record field migrated to encrypted", {
|
|
||||||
operation: "lazy_encryption_record_field_migrated",
|
|
||||||
recordId,
|
|
||||||
fieldName,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error("Failed to migrate record field", error, {
|
databaseLogger.error("Failed to migrate record field", error, {
|
||||||
operation: "lazy_encryption_record_field_failed",
|
operation: "lazy_encryption_record_field_failed",
|
||||||
|
|||||||
@@ -14,10 +14,23 @@ export interface LogContext {
|
|||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sensitive fields that should be masked in logs
|
||||||
|
const SENSITIVE_FIELDS = [
|
||||||
|
'password', 'passphrase', 'key', 'privateKey', 'publicKey', 'token', 'secret',
|
||||||
|
'clientSecret', 'keyPassword', 'autostartPassword', 'autostartKey', 'autostartKeyPassword',
|
||||||
|
'credentialId', 'authToken', 'jwt', 'session', 'cookie'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Fields that should be truncated if too long
|
||||||
|
const TRUNCATE_FIELDS = ['data', 'content', 'body', 'response', 'request'];
|
||||||
|
|
||||||
class Logger {
|
class Logger {
|
||||||
private serviceName: string;
|
private serviceName: string;
|
||||||
private serviceIcon: string;
|
private serviceIcon: string;
|
||||||
private serviceColor: string;
|
private serviceColor: string;
|
||||||
|
private logCounts = new Map<string, { count: number; lastLog: number }>();
|
||||||
|
private readonly RATE_LIMIT_WINDOW = 60000; // 1 minute
|
||||||
|
private readonly RATE_LIMIT_MAX = 10; // Max logs per minute for same message
|
||||||
|
|
||||||
constructor(serviceName: string, serviceIcon: string, serviceColor: string) {
|
constructor(serviceName: string, serviceIcon: string, serviceColor: string) {
|
||||||
this.serviceName = serviceName;
|
this.serviceName = serviceName;
|
||||||
@@ -29,6 +42,32 @@ class Logger {
|
|||||||
return chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
return chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sanitizeContext(context: LogContext): LogContext {
|
||||||
|
const sanitized = { ...context };
|
||||||
|
|
||||||
|
// Mask sensitive fields
|
||||||
|
for (const field of SENSITIVE_FIELDS) {
|
||||||
|
if (sanitized[field] !== undefined) {
|
||||||
|
if (typeof sanitized[field] === 'string' && sanitized[field].length > 0) {
|
||||||
|
sanitized[field] = '[MASKED]';
|
||||||
|
} else if (typeof sanitized[field] === 'boolean') {
|
||||||
|
sanitized[field] = sanitized[field] ? '[PRESENT]' : '[ABSENT]';
|
||||||
|
} else {
|
||||||
|
sanitized[field] = '[MASKED]';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate long fields
|
||||||
|
for (const field of TRUNCATE_FIELDS) {
|
||||||
|
if (sanitized[field] && typeof sanitized[field] === 'string' && sanitized[field].length > 100) {
|
||||||
|
sanitized[field] = sanitized[field].substring(0, 100) + '...';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
private formatMessage(
|
private formatMessage(
|
||||||
level: LogLevel,
|
level: LogLevel,
|
||||||
message: string,
|
message: string,
|
||||||
@@ -41,14 +80,15 @@ class Logger {
|
|||||||
|
|
||||||
let contextStr = "";
|
let contextStr = "";
|
||||||
if (context) {
|
if (context) {
|
||||||
|
const sanitizedContext = this.sanitizeContext(context);
|
||||||
const contextParts = [];
|
const contextParts = [];
|
||||||
if (context.operation) contextParts.push(`op:${context.operation}`);
|
if (sanitizedContext.operation) contextParts.push(`op:${sanitizedContext.operation}`);
|
||||||
if (context.userId) contextParts.push(`user:${context.userId}`);
|
if (sanitizedContext.userId) contextParts.push(`user:${sanitizedContext.userId}`);
|
||||||
if (context.hostId) contextParts.push(`host:${context.hostId}`);
|
if (sanitizedContext.hostId) contextParts.push(`host:${sanitizedContext.hostId}`);
|
||||||
if (context.tunnelName) contextParts.push(`tunnel:${context.tunnelName}`);
|
if (sanitizedContext.tunnelName) contextParts.push(`tunnel:${sanitizedContext.tunnelName}`);
|
||||||
if (context.sessionId) contextParts.push(`session:${context.sessionId}`);
|
if (sanitizedContext.sessionId) contextParts.push(`session:${sanitizedContext.sessionId}`);
|
||||||
if (context.requestId) contextParts.push(`req:${context.requestId}`);
|
if (sanitizedContext.requestId) contextParts.push(`req:${sanitizedContext.requestId}`);
|
||||||
if (context.duration) contextParts.push(`duration:${context.duration}ms`);
|
if (sanitizedContext.duration) contextParts.push(`duration:${sanitizedContext.duration}ms`);
|
||||||
|
|
||||||
if (contextParts.length > 0) {
|
if (contextParts.length > 0) {
|
||||||
contextStr = chalk.gray(` [${contextParts.join(",")}]`);
|
contextStr = chalk.gray(` [${contextParts.join(",")}]`);
|
||||||
@@ -75,30 +115,51 @@ class Logger {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private shouldLog(level: LogLevel): boolean {
|
private shouldLog(level: LogLevel, message: string): boolean {
|
||||||
if (level === "debug" && process.env.NODE_ENV === "production") {
|
if (level === "debug" && process.env.NODE_ENV === "production") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rate limiting for frequent messages
|
||||||
|
const now = Date.now();
|
||||||
|
const logKey = `${level}:${message}`;
|
||||||
|
const logInfo = this.logCounts.get(logKey);
|
||||||
|
|
||||||
|
if (logInfo) {
|
||||||
|
if (now - logInfo.lastLog < this.RATE_LIMIT_WINDOW) {
|
||||||
|
logInfo.count++;
|
||||||
|
if (logInfo.count > this.RATE_LIMIT_MAX) {
|
||||||
|
return false; // Rate limited
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Reset counter for new window
|
||||||
|
logInfo.count = 1;
|
||||||
|
logInfo.lastLog = now;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.logCounts.set(logKey, { count: 1, lastLog: now });
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
debug(message: string, context?: LogContext): void {
|
debug(message: string, context?: LogContext): void {
|
||||||
if (!this.shouldLog("debug")) return;
|
if (!this.shouldLog("debug", message)) return;
|
||||||
console.debug(this.formatMessage("debug", message, context));
|
console.debug(this.formatMessage("debug", message, context));
|
||||||
}
|
}
|
||||||
|
|
||||||
info(message: string, context?: LogContext): void {
|
info(message: string, context?: LogContext): void {
|
||||||
if (!this.shouldLog("info")) return;
|
if (!this.shouldLog("info", message)) return;
|
||||||
console.log(this.formatMessage("info", message, context));
|
console.log(this.formatMessage("info", message, context));
|
||||||
}
|
}
|
||||||
|
|
||||||
warn(message: string, context?: LogContext): void {
|
warn(message: string, context?: LogContext): void {
|
||||||
if (!this.shouldLog("warn")) return;
|
if (!this.shouldLog("warn", message)) return;
|
||||||
console.warn(this.formatMessage("warn", message, context));
|
console.warn(this.formatMessage("warn", message, context));
|
||||||
}
|
}
|
||||||
|
|
||||||
error(message: string, error?: unknown, context?: LogContext): void {
|
error(message: string, error?: unknown, context?: LogContext): void {
|
||||||
if (!this.shouldLog("error")) return;
|
if (!this.shouldLog("error", message)) return;
|
||||||
console.error(this.formatMessage("error", message, context));
|
console.error(this.formatMessage("error", message, context));
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -106,7 +167,7 @@ class Logger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
success(message: string, context?: LogContext): void {
|
success(message: string, context?: LogContext): void {
|
||||||
if (!this.shouldLog("success")) return;
|
if (!this.shouldLog("success", message)) return;
|
||||||
console.log(this.formatMessage("success", message, context));
|
console.log(this.formatMessage("success", message, context));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,13 +53,6 @@ class SimpleDBOps {
|
|||||||
userDataKey
|
userDataKey
|
||||||
);
|
);
|
||||||
|
|
||||||
databaseLogger.debug(`Inserted encrypted record into ${tableName}`, {
|
|
||||||
operation: "simple_insert",
|
|
||||||
table: tableName,
|
|
||||||
userId,
|
|
||||||
recordId: result[0].id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return decryptedResult as T;
|
return decryptedResult as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,13 +104,6 @@ class SimpleDBOps {
|
|||||||
userDataKey
|
userDataKey
|
||||||
);
|
);
|
||||||
|
|
||||||
databaseLogger.debug(`Selected single record from ${tableName}`, {
|
|
||||||
operation: "simple_select_one",
|
|
||||||
table: tableName,
|
|
||||||
userId,
|
|
||||||
recordId: result.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return decryptedResult;
|
return decryptedResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,13 +141,6 @@ class SimpleDBOps {
|
|||||||
userDataKey
|
userDataKey
|
||||||
);
|
);
|
||||||
|
|
||||||
databaseLogger.debug(`Updated records in ${tableName}`, {
|
|
||||||
operation: "simple_update",
|
|
||||||
table: tableName,
|
|
||||||
userId,
|
|
||||||
updatedCount: result.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
return decryptedResults as T[];
|
return decryptedResults as T[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ function detectKeyTypeFromContent(keyContent: string): string {
|
|||||||
// Default to RSA for OpenSSH format if we can't detect specifically
|
// Default to RSA for OpenSSH format if we can't detect specifically
|
||||||
return "ssh-rsa";
|
return "ssh-rsa";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Failed to decode OpenSSH key content:", error);
|
|
||||||
// If decoding fails, default to RSA as it's most common for OpenSSH format
|
// If decoding fails, default to RSA as it's most common for OpenSSH format
|
||||||
return "ssh-rsa";
|
return "ssh-rsa";
|
||||||
}
|
}
|
||||||
@@ -103,7 +102,6 @@ function detectKeyTypeFromContent(keyContent: string): string {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If decoding fails, fall back to length-based detection
|
// If decoding fails, fall back to length-based detection
|
||||||
console.warn("Failed to decode private key for type detection:", error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: Try to detect key type from the content structure
|
// Fallback: Try to detect key type from the content structure
|
||||||
@@ -176,7 +174,6 @@ function detectPublicKeyTypeFromContent(publicKeyContent: string): string {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If decoding fails, fall back to length-based detection
|
// If decoding fails, fall back to length-based detection
|
||||||
console.warn("Failed to decode public key for type detection:", error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: Try to guess based on key length
|
// Fallback: Try to guess based on key length
|
||||||
@@ -246,15 +243,6 @@ export function parseSSHKey(
|
|||||||
privateKeyData: string,
|
privateKeyData: string,
|
||||||
passphrase?: string,
|
passphrase?: string,
|
||||||
): KeyInfo {
|
): KeyInfo {
|
||||||
console.log("=== SSH Key Parsing Debug ===");
|
|
||||||
console.log("Key length:", privateKeyData?.length || "undefined");
|
|
||||||
console.log(
|
|
||||||
"First 100 chars:",
|
|
||||||
privateKeyData?.substring(0, 100) || "undefined",
|
|
||||||
);
|
|
||||||
console.log("ssh2Utils available:", typeof ssh2Utils);
|
|
||||||
console.log("parseKey function available:", typeof ssh2Utils?.parseKey);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let keyType = "unknown";
|
let keyType = "unknown";
|
||||||
let publicKey = "";
|
let publicKey = "";
|
||||||
@@ -263,30 +251,17 @@ export function parseSSHKey(
|
|||||||
// Try SSH2 first if available
|
// Try SSH2 first if available
|
||||||
if (ssh2Utils && typeof ssh2Utils.parseKey === "function") {
|
if (ssh2Utils && typeof ssh2Utils.parseKey === "function") {
|
||||||
try {
|
try {
|
||||||
console.log("Calling ssh2Utils.parseKey...");
|
|
||||||
const parsedKey = ssh2Utils.parseKey(privateKeyData, passphrase);
|
const parsedKey = ssh2Utils.parseKey(privateKeyData, passphrase);
|
||||||
console.log(
|
|
||||||
"parseKey returned:",
|
|
||||||
typeof parsedKey,
|
|
||||||
parsedKey instanceof Error ? parsedKey.message : "success",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!(parsedKey instanceof Error)) {
|
if (!(parsedKey instanceof Error)) {
|
||||||
// Extract key type
|
// Extract key type
|
||||||
if (parsedKey.type) {
|
if (parsedKey.type) {
|
||||||
keyType = parsedKey.type;
|
keyType = parsedKey.type;
|
||||||
}
|
}
|
||||||
console.log("Extracted key type:", keyType);
|
|
||||||
|
|
||||||
// Generate public key in SSH format
|
// Generate public key in SSH format
|
||||||
try {
|
try {
|
||||||
console.log("Attempting to generate public key...");
|
|
||||||
const publicKeyBuffer = parsedKey.getPublicSSH();
|
const publicKeyBuffer = parsedKey.getPublicSSH();
|
||||||
console.log("Public key buffer type:", typeof publicKeyBuffer);
|
|
||||||
console.log(
|
|
||||||
"Public key buffer is Buffer:",
|
|
||||||
Buffer.isBuffer(publicKeyBuffer),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ssh2's getPublicSSH() returns binary SSH protocol data, not text
|
// ssh2's getPublicSSH() returns binary SSH protocol data, not text
|
||||||
// We need to convert this to proper SSH public key format
|
// We need to convert this to proper SSH public key format
|
||||||
@@ -304,53 +279,26 @@ export function parseSSHKey(
|
|||||||
} else {
|
} else {
|
||||||
publicKey = `${keyType} ${base64Data}`;
|
publicKey = `${keyType} ${base64Data}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
|
||||||
"Generated SSH public key format, length:",
|
|
||||||
publicKey.length,
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
"Public key starts with:",
|
|
||||||
publicKey.substring(0, 50),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
console.warn("Unexpected public key buffer type");
|
|
||||||
publicKey = "";
|
publicKey = "";
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Failed to generate public key:", error);
|
|
||||||
publicKey = "";
|
publicKey = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
useSSH2 = true;
|
useSSH2 = true;
|
||||||
console.log(`SSH key parsed successfully with SSH2: ${keyType}`);
|
|
||||||
} else {
|
|
||||||
console.warn("SSH2 parsing failed:", parsedKey.message);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
// SSH2 parsing failed, will fall back to content detection
|
||||||
"SSH2 parsing exception:",
|
|
||||||
error instanceof Error ? error.message : error,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
console.warn("SSH2 parseKey function not available");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to content-based detection
|
// Fallback to content-based detection
|
||||||
if (!useSSH2) {
|
if (!useSSH2) {
|
||||||
console.log("Using fallback key type detection...");
|
|
||||||
keyType = detectKeyTypeFromContent(privateKeyData);
|
keyType = detectKeyTypeFromContent(privateKeyData);
|
||||||
console.log(`Fallback detected key type: ${keyType}`);
|
|
||||||
|
|
||||||
// For fallback, we can't generate public key but the detection is still useful
|
// For fallback, we can't generate public key but the detection is still useful
|
||||||
publicKey = "";
|
publicKey = "";
|
||||||
|
|
||||||
if (keyType !== "unknown") {
|
|
||||||
console.log(
|
|
||||||
`SSH key type detected successfully with fallback: ${keyType}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -360,17 +308,10 @@ export function parseSSHKey(
|
|||||||
success: keyType !== "unknown",
|
success: keyType !== "unknown",
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Exception during SSH key parsing:", error);
|
|
||||||
console.error(
|
|
||||||
"Error stack:",
|
|
||||||
error instanceof Error ? error.stack : "No stack",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Final fallback - try content detection
|
// Final fallback - try content detection
|
||||||
try {
|
try {
|
||||||
const fallbackKeyType = detectKeyTypeFromContent(privateKeyData);
|
const fallbackKeyType = detectKeyTypeFromContent(privateKeyData);
|
||||||
if (fallbackKeyType !== "unknown") {
|
if (fallbackKeyType !== "unknown") {
|
||||||
console.log(`Final fallback detection successful: ${fallbackKeyType}`);
|
|
||||||
return {
|
return {
|
||||||
privateKey: privateKeyData,
|
privateKey: privateKeyData,
|
||||||
publicKey: "",
|
publicKey: "",
|
||||||
@@ -379,7 +320,7 @@ export function parseSSHKey(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (fallbackError) {
|
} catch (fallbackError) {
|
||||||
console.error("Even fallback detection failed:", fallbackError);
|
// Even fallback detection failed
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -397,16 +338,8 @@ export function parseSSHKey(
|
|||||||
* Parse SSH public key and extract type information
|
* Parse SSH public key and extract type information
|
||||||
*/
|
*/
|
||||||
export function parsePublicKey(publicKeyData: string): PublicKeyInfo {
|
export function parsePublicKey(publicKeyData: string): PublicKeyInfo {
|
||||||
console.log("=== SSH Public Key Parsing Debug ===");
|
|
||||||
console.log("Public key length:", publicKeyData?.length || "undefined");
|
|
||||||
console.log(
|
|
||||||
"First 100 chars:",
|
|
||||||
publicKeyData?.substring(0, 100) || "undefined",
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const keyType = detectPublicKeyTypeFromContent(publicKeyData);
|
const keyType = detectPublicKeyTypeFromContent(publicKeyData);
|
||||||
console.log(`Public key type detected: ${keyType}`);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
publicKey: publicKeyData,
|
publicKey: publicKeyData,
|
||||||
@@ -414,7 +347,6 @@ export function parsePublicKey(publicKeyData: string): PublicKeyInfo {
|
|||||||
success: keyType !== "unknown",
|
success: keyType !== "unknown",
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Exception during SSH public key parsing:", error);
|
|
||||||
return {
|
return {
|
||||||
publicKey: publicKeyData,
|
publicKey: publicKeyData,
|
||||||
keyType: "unknown",
|
keyType: "unknown",
|
||||||
@@ -469,26 +401,11 @@ export function validateKeyPair(
|
|||||||
publicKeyData: string,
|
publicKeyData: string,
|
||||||
passphrase?: string,
|
passphrase?: string,
|
||||||
): KeyPairValidationResult {
|
): KeyPairValidationResult {
|
||||||
console.log("=== Key Pair Validation Debug ===");
|
|
||||||
console.log("Private key length:", privateKeyData?.length || "undefined");
|
|
||||||
console.log("Public key length:", publicKeyData?.length || "undefined");
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// First parse the private key and try to generate public key
|
// First parse the private key and try to generate public key
|
||||||
const privateKeyInfo = parseSSHKey(privateKeyData, passphrase);
|
const privateKeyInfo = parseSSHKey(privateKeyData, passphrase);
|
||||||
const publicKeyInfo = parsePublicKey(publicKeyData);
|
const publicKeyInfo = parsePublicKey(publicKeyData);
|
||||||
|
|
||||||
console.log(
|
|
||||||
"Private key parsing result:",
|
|
||||||
privateKeyInfo.success,
|
|
||||||
privateKeyInfo.keyType,
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
"Public key parsing result:",
|
|
||||||
publicKeyInfo.success,
|
|
||||||
publicKeyInfo.keyType,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!privateKeyInfo.success) {
|
if (!privateKeyInfo.success) {
|
||||||
return {
|
return {
|
||||||
isValid: false,
|
isValid: false,
|
||||||
@@ -522,9 +439,6 @@ export function validateKeyPair(
|
|||||||
const generatedPublicKey = privateKeyInfo.publicKey.trim();
|
const generatedPublicKey = privateKeyInfo.publicKey.trim();
|
||||||
const providedPublicKey = publicKeyData.trim();
|
const providedPublicKey = publicKeyData.trim();
|
||||||
|
|
||||||
console.log("Generated public key length:", generatedPublicKey.length);
|
|
||||||
console.log("Provided public key length:", providedPublicKey.length);
|
|
||||||
|
|
||||||
// Compare the key data part (excluding comments)
|
// Compare the key data part (excluding comments)
|
||||||
const generatedKeyParts = generatedPublicKey.split(" ");
|
const generatedKeyParts = generatedPublicKey.split(" ");
|
||||||
const providedKeyParts = providedPublicKey.split(" ");
|
const providedKeyParts = providedPublicKey.split(" ");
|
||||||
@@ -535,15 +449,6 @@ export function validateKeyPair(
|
|||||||
generatedKeyParts[0] + " " + generatedKeyParts[1];
|
generatedKeyParts[0] + " " + generatedKeyParts[1];
|
||||||
const providedKeyData = providedKeyParts[0] + " " + providedKeyParts[1];
|
const providedKeyData = providedKeyParts[0] + " " + providedKeyParts[1];
|
||||||
|
|
||||||
console.log(
|
|
||||||
"Generated key data:",
|
|
||||||
generatedKeyData.substring(0, 50) + "...",
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
"Provided key data:",
|
|
||||||
providedKeyData.substring(0, 50) + "...",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (generatedKeyData === providedKeyData) {
|
if (generatedKeyData === providedKeyData) {
|
||||||
return {
|
return {
|
||||||
isValid: true,
|
isValid: true,
|
||||||
@@ -571,7 +476,6 @@ export function validateKeyPair(
|
|||||||
error: "Unable to verify key pair match, but key types are compatible",
|
error: "Unable to verify key pair match, but key types are compatible",
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Exception during key pair validation:", error);
|
|
||||||
return {
|
return {
|
||||||
isValid: false,
|
isValid: false,
|
||||||
privateKeyType: "unknown",
|
privateKeyType: "unknown",
|
||||||
|
|||||||
@@ -41,10 +41,6 @@ class SystemCrypto {
|
|||||||
const envSecret = process.env.JWT_SECRET;
|
const envSecret = process.env.JWT_SECRET;
|
||||||
if (envSecret && envSecret.length >= 64) {
|
if (envSecret && envSecret.length >= 64) {
|
||||||
this.jwtSecret = envSecret;
|
this.jwtSecret = envSecret;
|
||||||
databaseLogger.info("✅ Using JWT secret from environment variable", {
|
|
||||||
operation: "jwt_env_loaded",
|
|
||||||
source: "environment"
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,10 +78,6 @@ class SystemCrypto {
|
|||||||
const envKey = process.env.DATABASE_KEY;
|
const envKey = process.env.DATABASE_KEY;
|
||||||
if (envKey && envKey.length >= 64) {
|
if (envKey && envKey.length >= 64) {
|
||||||
this.databaseKey = Buffer.from(envKey, 'hex');
|
this.databaseKey = Buffer.from(envKey, 'hex');
|
||||||
databaseLogger.info("✅ Using database key from environment variable", {
|
|
||||||
operation: "db_key_env_loaded",
|
|
||||||
source: "environment"
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,10 +115,6 @@ class SystemCrypto {
|
|||||||
const envToken = process.env.INTERNAL_AUTH_TOKEN;
|
const envToken = process.env.INTERNAL_AUTH_TOKEN;
|
||||||
if (envToken && envToken.length >= 32) {
|
if (envToken && envToken.length >= 32) {
|
||||||
this.internalAuthToken = envToken;
|
this.internalAuthToken = envToken;
|
||||||
databaseLogger.info("✅ Using internal auth token from environment variable", {
|
|
||||||
operation: "internal_auth_env_loaded",
|
|
||||||
source: "environment"
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +152,7 @@ class SystemCrypto {
|
|||||||
// Auto-save to .env file
|
// Auto-save to .env file
|
||||||
await this.updateEnvFile("JWT_SECRET", newSecret);
|
await this.updateEnvFile("JWT_SECRET", newSecret);
|
||||||
|
|
||||||
databaseLogger.success("🔐 JWT secret auto-generated and saved to .env", {
|
databaseLogger.success("JWT secret auto-generated and saved to .env", {
|
||||||
operation: "jwt_auto_generated",
|
operation: "jwt_auto_generated",
|
||||||
instanceId,
|
instanceId,
|
||||||
envVarName: "JWT_SECRET",
|
envVarName: "JWT_SECRET",
|
||||||
@@ -210,7 +198,7 @@ class SystemCrypto {
|
|||||||
// Auto-save to .env file
|
// Auto-save to .env file
|
||||||
await this.updateEnvFile("INTERNAL_AUTH_TOKEN", newToken);
|
await this.updateEnvFile("INTERNAL_AUTH_TOKEN", newToken);
|
||||||
|
|
||||||
databaseLogger.success("🔑 Internal auth token auto-generated and saved to .env", {
|
databaseLogger.success("Internal auth token auto-generated and saved to .env", {
|
||||||
operation: "internal_auth_auto_generated",
|
operation: "internal_auth_auto_generated",
|
||||||
instanceId,
|
instanceId,
|
||||||
envVarName: "INTERNAL_AUTH_TOKEN",
|
envVarName: "INTERNAL_AUTH_TOKEN",
|
||||||
|
|||||||
@@ -87,7 +87,7 @@
|
|||||||
"keyPassphraseOptional": "Optional: leave empty if your key has no passphrase",
|
"keyPassphraseOptional": "Optional: leave empty if your key has no passphrase",
|
||||||
"leaveEmptyToKeepCurrent": "Leave empty to keep current value",
|
"leaveEmptyToKeepCurrent": "Leave empty to keep current value",
|
||||||
"uploadKeyFile": "Upload Key File",
|
"uploadKeyFile": "Upload Key File",
|
||||||
"generateKeyPair": "Generate Key Pair",
|
"generateKeyPairButton": "Generate Key Pair",
|
||||||
"sshKeyGenerationNotImplemented": "SSH key generation feature coming soon",
|
"sshKeyGenerationNotImplemented": "SSH key generation feature coming soon",
|
||||||
"connectionTestingNotImplemented": "Connection testing feature coming soon",
|
"connectionTestingNotImplemented": "Connection testing feature coming soon",
|
||||||
"testConnection": "Test Connection",
|
"testConnection": "Test Connection",
|
||||||
@@ -123,7 +123,7 @@
|
|||||||
"editCredentialDescription": "Update the credential information",
|
"editCredentialDescription": "Update the credential information",
|
||||||
"listView": "List",
|
"listView": "List",
|
||||||
"folderView": "Folders",
|
"folderView": "Folders",
|
||||||
"unknown": "Unknown",
|
"unknownCredential": "Unknown",
|
||||||
"confirmRemoveFromFolder": "Are you sure you want to remove \"{{name}}\" from folder \"{{folder}}\"? The credential will be moved to \"Uncategorized\".",
|
"confirmRemoveFromFolder": "Are you sure you want to remove \"{{name}}\" from folder \"{{folder}}\"? The credential will be moved to \"Uncategorized\".",
|
||||||
"removedFromFolder": "Credential \"{{name}}\" removed from folder successfully",
|
"removedFromFolder": "Credential \"{{name}}\" removed from folder successfully",
|
||||||
"failedToRemoveFromFolder": "Failed to remove credential from folder",
|
"failedToRemoveFromFolder": "Failed to remove credential from folder",
|
||||||
@@ -144,7 +144,7 @@
|
|||||||
"detectedKeyType": "Detected key type",
|
"detectedKeyType": "Detected key type",
|
||||||
"detectingKeyType": "detecting...",
|
"detectingKeyType": "detecting...",
|
||||||
"optional": "Optional",
|
"optional": "Optional",
|
||||||
"generateKeyPair": "Generate New Key Pair",
|
"generateKeyPairNew": "Generate New Key Pair",
|
||||||
"generateEd25519": "Generate Ed25519",
|
"generateEd25519": "Generate Ed25519",
|
||||||
"generateECDSA": "Generate ECDSA",
|
"generateECDSA": "Generate ECDSA",
|
||||||
"generateRSA": "Generate RSA",
|
"generateRSA": "Generate RSA",
|
||||||
@@ -155,6 +155,16 @@
|
|||||||
"detectionError": "Detection Error",
|
"detectionError": "Detection Error",
|
||||||
"unknown": "Unknown"
|
"unknown": "Unknown"
|
||||||
},
|
},
|
||||||
|
"dragIndicator": {
|
||||||
|
"error": "Error: {{error}}",
|
||||||
|
"dragging": "Dragging {{fileName}}",
|
||||||
|
"preparing": "Preparing {{fileName}}",
|
||||||
|
"readySingle": "Ready to download {{fileName}}",
|
||||||
|
"readyMultiple": "Ready to download {{count}} files",
|
||||||
|
"batchDrag": "Drag {{count}} files to desktop",
|
||||||
|
"dragToDesktop": "Drag to desktop",
|
||||||
|
"canDragAnywhere": "You can drag files anywhere on your desktop"
|
||||||
|
},
|
||||||
"sshTools": {
|
"sshTools": {
|
||||||
"title": "SSH Tools",
|
"title": "SSH Tools",
|
||||||
"closeTools": "Close SSH Tools",
|
"closeTools": "Close SSH Tools",
|
||||||
@@ -190,7 +200,7 @@
|
|||||||
"saveError": "Error saving configuration",
|
"saveError": "Error saving configuration",
|
||||||
"saving": "Saving...",
|
"saving": "Saving...",
|
||||||
"saveConfig": "Save Configuration",
|
"saveConfig": "Save Configuration",
|
||||||
"helpText": "Enter the URL where your Termix server is running (e.g., http://localhost:8081 or https://your-server.com)"
|
"helpText": "Enter the URL where your Termix server is running (e.g., http://localhost:30001 or https://your-server.com)"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
@@ -294,7 +304,10 @@
|
|||||||
"failedToInitiatePasswordReset": "Failed to initiate password reset",
|
"failedToInitiatePasswordReset": "Failed to initiate password reset",
|
||||||
"failedToVerifyResetCode": "Failed to verify reset code",
|
"failedToVerifyResetCode": "Failed to verify reset code",
|
||||||
"failedToCompletePasswordReset": "Failed to complete password reset",
|
"failedToCompletePasswordReset": "Failed to complete password reset",
|
||||||
"documentation": "Documentation"
|
"documentation": "Documentation",
|
||||||
|
"retry": "Retry",
|
||||||
|
"checking": "Checking...",
|
||||||
|
"checkingDatabase": "Checking database connection..."
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
@@ -719,6 +732,9 @@
|
|||||||
"failedToCreateFile": "Failed to create file",
|
"failedToCreateFile": "Failed to create file",
|
||||||
"folderCreatedSuccessfully": "Folder \"{{name}}\" created successfully",
|
"folderCreatedSuccessfully": "Folder \"{{name}}\" created successfully",
|
||||||
"failedToCreateFolder": "Failed to create folder",
|
"failedToCreateFolder": "Failed to create folder",
|
||||||
|
"failedToCreateItem": "Failed to create item",
|
||||||
|
"operationFailed": "{{operation}} operation failed for {{name}}: {{error}}",
|
||||||
|
"failedToResolveSymlink": "Failed to resolve symlink",
|
||||||
"itemDeletedSuccessfully": "{{type}} deleted successfully",
|
"itemDeletedSuccessfully": "{{type}} deleted successfully",
|
||||||
"itemsDeletedSuccessfully": "{{count}} items deleted successfully",
|
"itemsDeletedSuccessfully": "{{count}} items deleted successfully",
|
||||||
"failedToDeleteItems": "Failed to delete items",
|
"failedToDeleteItems": "Failed to delete items",
|
||||||
@@ -774,7 +790,7 @@
|
|||||||
"serverError": "Server Error",
|
"serverError": "Server Error",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"requestFailed": "Request failed with status code",
|
"requestFailed": "Request failed with status code",
|
||||||
"unknown": "unknown",
|
"unknownFileError": "unknown",
|
||||||
"cannotReadFile": "Cannot read file",
|
"cannotReadFile": "Cannot read file",
|
||||||
"noSshSessionId": "No SSH session ID available",
|
"noSshSessionId": "No SSH session ID available",
|
||||||
"noFilePath": "No file path available",
|
"noFilePath": "No file path available",
|
||||||
@@ -925,7 +941,7 @@
|
|||||||
"disconnected": "Disconnected",
|
"disconnected": "Disconnected",
|
||||||
"connecting": "Connecting...",
|
"connecting": "Connecting...",
|
||||||
"disconnecting": "Disconnecting...",
|
"disconnecting": "Disconnecting...",
|
||||||
"unknown": "Unknown",
|
"unknownTunnelStatus": "Unknown",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"failed": "Failed",
|
"failed": "Failed",
|
||||||
"retrying": "Retrying",
|
"retrying": "Retrying",
|
||||||
@@ -962,7 +978,7 @@
|
|||||||
"dynamic": "Dynamic",
|
"dynamic": "Dynamic",
|
||||||
"noSshTunnels": "No SSH Tunnels",
|
"noSshTunnels": "No SSH Tunnels",
|
||||||
"createFirstTunnelMessage": "Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel connections.",
|
"createFirstTunnelMessage": "Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel connections.",
|
||||||
"unknown": "Unknown",
|
"unknownConnectionStatus": "Unknown",
|
||||||
"connected": "Connected",
|
"connected": "Connected",
|
||||||
"connecting": "Connecting...",
|
"connecting": "Connecting...",
|
||||||
"disconnecting": "Disconnecting...",
|
"disconnecting": "Disconnecting...",
|
||||||
@@ -1105,7 +1121,7 @@
|
|||||||
"forbidden": "Access forbidden",
|
"forbidden": "Access forbidden",
|
||||||
"serverError": "Server error",
|
"serverError": "Server error",
|
||||||
"networkError": "Network error",
|
"networkError": "Network error",
|
||||||
"databaseConnection": "Could not connect to the database. Please try again later.",
|
"databaseConnection": "Could not connect to the database.",
|
||||||
"unknownError": "Unknown error",
|
"unknownError": "Unknown error",
|
||||||
"failedPasswordReset": "Failed to initiate password reset",
|
"failedPasswordReset": "Failed to initiate password reset",
|
||||||
"failedVerifyCode": "Failed to verify reset code",
|
"failedVerifyCode": "Failed to verify reset code",
|
||||||
@@ -1143,7 +1159,15 @@
|
|||||||
"reconnecting": "Reconnecting...",
|
"reconnecting": "Reconnecting...",
|
||||||
"processing": "Processing...",
|
"processing": "Processing...",
|
||||||
"pleaseWait": "Please wait...",
|
"pleaseWait": "Please wait...",
|
||||||
"registrationDisabled": "New account registration is currently disabled by an admin. Please log in or contact an administrator."
|
"registrationDisabled": "New account registration is currently disabled by an admin. Please log in or contact an administrator.",
|
||||||
|
"databaseConnected": "Database connected successfully",
|
||||||
|
"databaseConnectionFailed": "Failed to connect to the database server",
|
||||||
|
"checkServerConnection": "Please check your server connection and try again",
|
||||||
|
"resetCodeSent": "Reset code sent to your email",
|
||||||
|
"codeVerified": "Code verified successfully",
|
||||||
|
"passwordResetSuccess": "Password reset successfully",
|
||||||
|
"loginSuccess": "Login successful",
|
||||||
|
"registrationSuccess": "Registration successful"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"title": "User Profile",
|
"title": "User Profile",
|
||||||
@@ -1306,6 +1330,9 @@
|
|||||||
"updateKey": "Update Key",
|
"updateKey": "Update Key",
|
||||||
"productionFolder": "Production",
|
"productionFolder": "Production",
|
||||||
"databaseServer": "Database Server",
|
"databaseServer": "Database Server",
|
||||||
|
"developmentServer": "Development Server",
|
||||||
|
"developmentFolder": "Development",
|
||||||
|
"webServerProduction": "Web Server - Production",
|
||||||
"unknownError": "Unknown error",
|
"unknownError": "Unknown error",
|
||||||
"failedToInitiatePasswordReset": "Failed to initiate password reset",
|
"failedToInitiatePasswordReset": "Failed to initiate password reset",
|
||||||
"failedToVerifyResetCode": "Failed to verify reset code",
|
"failedToVerifyResetCode": "Failed to verify reset code",
|
||||||
|
|||||||
@@ -87,7 +87,7 @@
|
|||||||
"keyPassphraseOptional": "可选:如果您的密钥没有密码,请留空",
|
"keyPassphraseOptional": "可选:如果您的密钥没有密码,请留空",
|
||||||
"leaveEmptyToKeepCurrent": "留空以保持当前值",
|
"leaveEmptyToKeepCurrent": "留空以保持当前值",
|
||||||
"uploadKeyFile": "上传密钥文件",
|
"uploadKeyFile": "上传密钥文件",
|
||||||
"generateKeyPair": "生成密钥对",
|
"generateKeyPairButton": "生成密钥对",
|
||||||
"sshKeyGenerationNotImplemented": "SSH密钥生成功能即将推出",
|
"sshKeyGenerationNotImplemented": "SSH密钥生成功能即将推出",
|
||||||
"connectionTestingNotImplemented": "连接测试功能即将推出",
|
"connectionTestingNotImplemented": "连接测试功能即将推出",
|
||||||
"testConnection": "测试连接",
|
"testConnection": "测试连接",
|
||||||
@@ -122,7 +122,7 @@
|
|||||||
"editCredentialDescription": "更新凭据信息",
|
"editCredentialDescription": "更新凭据信息",
|
||||||
"listView": "列表",
|
"listView": "列表",
|
||||||
"folderView": "文件夹",
|
"folderView": "文件夹",
|
||||||
"unknown": "未知",
|
"unknownCredential": "未知",
|
||||||
"confirmRemoveFromFolder": "确定要将\"{{name}}\"从文件夹\"{{folder}}\"中移除吗?凭据将被移动到\"未分类\"。",
|
"confirmRemoveFromFolder": "确定要将\"{{name}}\"从文件夹\"{{folder}}\"中移除吗?凭据将被移动到\"未分类\"。",
|
||||||
"removedFromFolder": "凭据\"{{name}}\"已成功从文件夹中移除",
|
"removedFromFolder": "凭据\"{{name}}\"已成功从文件夹中移除",
|
||||||
"failedToRemoveFromFolder": "从文件夹中移除凭据失败",
|
"failedToRemoveFromFolder": "从文件夹中移除凭据失败",
|
||||||
@@ -143,7 +143,7 @@
|
|||||||
"detectedKeyType": "检测到的密钥类型",
|
"detectedKeyType": "检测到的密钥类型",
|
||||||
"detectingKeyType": "检测中...",
|
"detectingKeyType": "检测中...",
|
||||||
"optional": "可选",
|
"optional": "可选",
|
||||||
"generateKeyPair": "生成新的密钥对",
|
"generateKeyPairNew": "生成新的密钥对",
|
||||||
"generateEd25519": "生成 Ed25519",
|
"generateEd25519": "生成 Ed25519",
|
||||||
"generateECDSA": "生成 ECDSA",
|
"generateECDSA": "生成 ECDSA",
|
||||||
"generateRSA": "生成 RSA",
|
"generateRSA": "生成 RSA",
|
||||||
@@ -151,8 +151,17 @@
|
|||||||
"failedToGenerateKeyPair": "生成密钥对失败",
|
"failedToGenerateKeyPair": "生成密钥对失败",
|
||||||
"generateKeyPairNote": "直接生成新的SSH密钥对。这将替换表单中的现有密钥。",
|
"generateKeyPairNote": "直接生成新的SSH密钥对。这将替换表单中的现有密钥。",
|
||||||
"invalidKey": "无效密钥",
|
"invalidKey": "无效密钥",
|
||||||
"detectionError": "检测错误",
|
"detectionError": "检测错误"
|
||||||
"unknown": "未知"
|
},
|
||||||
|
"dragIndicator": {
|
||||||
|
"error": "错误:{{error}}",
|
||||||
|
"dragging": "正在拖拽 {{fileName}}",
|
||||||
|
"preparing": "正在准备 {{fileName}}",
|
||||||
|
"readySingle": "准备下载 {{fileName}}",
|
||||||
|
"readyMultiple": "准备下载 {{count}} 个文件",
|
||||||
|
"batchDrag": "拖拽 {{count}} 个文件到桌面",
|
||||||
|
"dragToDesktop": "拖拽到桌面",
|
||||||
|
"canDragAnywhere": "您可以将文件拖拽到桌面的任何位置"
|
||||||
},
|
},
|
||||||
"sshTools": {
|
"sshTools": {
|
||||||
"title": "SSH 工具",
|
"title": "SSH 工具",
|
||||||
@@ -189,7 +198,7 @@
|
|||||||
"saveError": "保存配置时出错",
|
"saveError": "保存配置时出错",
|
||||||
"saving": "保存中...",
|
"saving": "保存中...",
|
||||||
"saveConfig": "保存配置",
|
"saveConfig": "保存配置",
|
||||||
"helpText": "输入您的 Termix 服务器运行地址(例如:http://localhost:8081 或 https://your-server.com)"
|
"helpText": "输入您的 Termix 服务器运行地址(例如:http://localhost:30001 或 https://your-server.com)"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"close": "关闭",
|
"close": "关闭",
|
||||||
@@ -281,7 +290,10 @@
|
|||||||
"failedToInitiatePasswordReset": "启动密码重置失败",
|
"failedToInitiatePasswordReset": "启动密码重置失败",
|
||||||
"failedToVerifyResetCode": "验证重置代码失败",
|
"failedToVerifyResetCode": "验证重置代码失败",
|
||||||
"failedToCompletePasswordReset": "完成密码重置失败",
|
"failedToCompletePasswordReset": "完成密码重置失败",
|
||||||
"documentation": "文档"
|
"documentation": "文档",
|
||||||
|
"retry": "重试",
|
||||||
|
"checking": "检查中...",
|
||||||
|
"checkingDatabase": "正在检查数据库连接..."
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"home": "首页",
|
"home": "首页",
|
||||||
@@ -742,6 +754,9 @@
|
|||||||
"failedToCreateFile": "创建文件失败",
|
"failedToCreateFile": "创建文件失败",
|
||||||
"folderCreatedSuccessfully": "文件夹 \"{{name}}\" 创建成功",
|
"folderCreatedSuccessfully": "文件夹 \"{{name}}\" 创建成功",
|
||||||
"failedToCreateFolder": "创建文件夹失败",
|
"failedToCreateFolder": "创建文件夹失败",
|
||||||
|
"failedToCreateItem": "创建项目失败",
|
||||||
|
"operationFailed": "{{operation}} 操作失败,文件 {{name}}:{{error}}",
|
||||||
|
"failedToResolveSymlink": "解析符号链接失败",
|
||||||
"itemDeletedSuccessfully": "{{type}}删除成功",
|
"itemDeletedSuccessfully": "{{type}}删除成功",
|
||||||
"itemsDeletedSuccessfully": "{{count}} 个项目删除成功",
|
"itemsDeletedSuccessfully": "{{count}} 个项目删除成功",
|
||||||
"failedToDeleteItems": "删除项目失败",
|
"failedToDeleteItems": "删除项目失败",
|
||||||
@@ -800,7 +815,7 @@
|
|||||||
"serverError": "服务器错误",
|
"serverError": "服务器错误",
|
||||||
"error": "错误",
|
"error": "错误",
|
||||||
"requestFailed": "请求失败,状态码",
|
"requestFailed": "请求失败,状态码",
|
||||||
"unknown": "未知",
|
"unknownFileError": "未知",
|
||||||
"cannotReadFile": "无法读取文件",
|
"cannotReadFile": "无法读取文件",
|
||||||
"noSshSessionId": "没有可用的 SSH 会话 ID",
|
"noSshSessionId": "没有可用的 SSH 会话 ID",
|
||||||
"noFilePath": "没有可用的文件路径",
|
"noFilePath": "没有可用的文件路径",
|
||||||
@@ -940,7 +955,7 @@
|
|||||||
"disconnected": "已断开连接",
|
"disconnected": "已断开连接",
|
||||||
"connecting": "连接中...",
|
"connecting": "连接中...",
|
||||||
"disconnecting": "断开连接中...",
|
"disconnecting": "断开连接中...",
|
||||||
"unknown": "未知",
|
"unknownTunnelStatus": "未知",
|
||||||
"error": "错误",
|
"error": "错误",
|
||||||
"failed": "失败",
|
"failed": "失败",
|
||||||
"retrying": "重试中",
|
"retrying": "重试中",
|
||||||
@@ -1106,7 +1121,7 @@
|
|||||||
"forbidden": "访问被禁止",
|
"forbidden": "访问被禁止",
|
||||||
"serverError": "服务器错误",
|
"serverError": "服务器错误",
|
||||||
"networkError": "网络错误",
|
"networkError": "网络错误",
|
||||||
"databaseConnection": "无法连接到数据库。请稍后再试。",
|
"databaseConnection": "无法连接到数据库。",
|
||||||
"unknownError": "未知错误",
|
"unknownError": "未知错误",
|
||||||
"failedPasswordReset": "无法启动密码重置",
|
"failedPasswordReset": "无法启动密码重置",
|
||||||
"failedVerifyCode": "验证重置代码失败",
|
"failedVerifyCode": "验证重置代码失败",
|
||||||
@@ -1144,7 +1159,15 @@
|
|||||||
"reconnecting": "重新连接中...",
|
"reconnecting": "重新连接中...",
|
||||||
"processing": "处理中...",
|
"processing": "处理中...",
|
||||||
"pleaseWait": "请稍候...",
|
"pleaseWait": "请稍候...",
|
||||||
"registrationDisabled": "新用户注册已被管理员禁用。请登录或联系管理员。"
|
"registrationDisabled": "新用户注册已被管理员禁用。请登录或联系管理员。",
|
||||||
|
"databaseConnected": "数据库连接成功",
|
||||||
|
"databaseConnectionFailed": "无法连接到数据库服务器",
|
||||||
|
"checkServerConnection": "请检查您的服务器连接并重试",
|
||||||
|
"resetCodeSent": "重置代码已发送到您的邮箱",
|
||||||
|
"codeVerified": "代码验证成功",
|
||||||
|
"passwordResetSuccess": "密码重置成功",
|
||||||
|
"loginSuccess": "登录成功",
|
||||||
|
"registrationSuccess": "注册成功"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"title": "用户资料",
|
"title": "用户资料",
|
||||||
@@ -1308,6 +1331,9 @@
|
|||||||
"sshServerConfigRequired": "需要 SSH 服务器配置",
|
"sshServerConfigRequired": "需要 SSH 服务器配置",
|
||||||
"productionFolder": "生产环境",
|
"productionFolder": "生产环境",
|
||||||
"databaseServer": "数据库服务器",
|
"databaseServer": "数据库服务器",
|
||||||
|
"developmentServer": "开发服务器",
|
||||||
|
"developmentFolder": "开发环境",
|
||||||
|
"webServerProduction": "Web 服务器 - 生产环境",
|
||||||
"unknownError": "未知错误",
|
"unknownError": "未知错误",
|
||||||
"failedToInitiatePasswordReset": "启动密码重置失败",
|
"failedToInitiatePasswordReset": "启动密码重置失败",
|
||||||
"failedToVerifyResetCode": "验证重置代码失败",
|
"failedToVerifyResetCode": "验证重置代码失败",
|
||||||
|
|||||||
@@ -383,26 +383,6 @@ export interface FileManagerProps {
|
|||||||
initialHost?: SSHHost | null;
|
initialHost?: SSHHost | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileManagerLeftSidebarProps {
|
|
||||||
onSelectView?: (view: string) => void;
|
|
||||||
onOpenFile: (file: any) => void;
|
|
||||||
tabs: Tab[];
|
|
||||||
host: SSHHost;
|
|
||||||
onOperationComplete?: () => void;
|
|
||||||
onError?: (error: string) => void;
|
|
||||||
onSuccess?: (message: string) => void;
|
|
||||||
onPathChange?: (path: string) => void;
|
|
||||||
onDeleteItem?: (item: any) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FileManagerOperationsProps {
|
|
||||||
currentPath: string;
|
|
||||||
sshSessionId: string | null;
|
|
||||||
onOperationComplete?: () => void;
|
|
||||||
onError?: (error: string) => void;
|
|
||||||
onSuccess?: (message: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AlertCardProps {
|
export interface AlertCardProps {
|
||||||
alert: TermixAlert;
|
alert: TermixAlert;
|
||||||
onDismiss: (alertId: string) => void;
|
onDismiss: (alertId: string) => void;
|
||||||
|
|||||||
@@ -295,7 +295,7 @@ export function AdminSettings({
|
|||||||
const jwt = getCookie("jwt");
|
const jwt = getCookie("jwt");
|
||||||
const apiUrl = isElectron()
|
const apiUrl = isElectron()
|
||||||
? `${(window as any).configuredServerUrl}/database/export`
|
? `${(window as any).configuredServerUrl}/database/export`
|
||||||
: "http://localhost:8081/database/export";
|
: "http://localhost:30001/database/export";
|
||||||
|
|
||||||
const response = await fetch(apiUrl, {
|
const response = await fetch(apiUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -355,7 +355,7 @@ export function AdminSettings({
|
|||||||
const jwt = getCookie("jwt");
|
const jwt = getCookie("jwt");
|
||||||
const apiUrl = isElectron()
|
const apiUrl = isElectron()
|
||||||
? `${(window as any).configuredServerUrl}/database/import`
|
? `${(window as any).configuredServerUrl}/database/import`
|
||||||
: "http://localhost:8081/database/import";
|
: "http://localhost:30001/database/import";
|
||||||
|
|
||||||
// Create FormData for file upload
|
// Create FormData for file upload
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@@ -927,12 +927,29 @@ export function AdminSettings({
|
|||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t("admin.importDescription")}
|
{t("admin.importDescription")}
|
||||||
</p>
|
</p>
|
||||||
<input
|
<div className="relative inline-block w-full mb-2">
|
||||||
type="file"
|
<input
|
||||||
accept=".sqlite,.db"
|
id="import-file-upload"
|
||||||
onChange={(e) => setImportFile(e.target.files?.[0] || null)}
|
type="file"
|
||||||
className="block w-full text-xs file:mr-2 file:py-1 file:px-2 file:rounded file:border-0 file:text-xs file:bg-muted file:text-foreground mb-2"
|
accept=".sqlite,.db"
|
||||||
/>
|
onChange={(e) => setImportFile(e.target.files?.[0] || null)}
|
||||||
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start text-left"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="truncate"
|
||||||
|
title={importFile?.name || t("admin.pleaseSelectImportFile")}
|
||||||
|
>
|
||||||
|
{importFile
|
||||||
|
? importFile.name
|
||||||
|
: t("admin.pleaseSelectImportFile")}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
{importFile && (
|
{importFile && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="import-password">Password</Label>
|
<Label htmlFor="import-password">Password</Label>
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { FileManagerTabList } from "./FileManagerTabList.tsx";
|
|
||||||
|
|
||||||
interface FileManagerTopNavbarProps {
|
|
||||||
tabs: { id: string | number; title: string }[];
|
|
||||||
activeTab: string | number;
|
|
||||||
setActiveTab: (tab: string | number) => void;
|
|
||||||
closeTab: (tab: string | number) => void;
|
|
||||||
onHomeClick: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FIleManagerTopNavbar(
|
|
||||||
props: FileManagerTopNavbarProps,
|
|
||||||
): React.ReactElement {
|
|
||||||
const { tabs, activeTab, setActiveTab, closeTab, onHomeClick } = props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FileManagerTabList
|
|
||||||
tabs={tabs}
|
|
||||||
activeTab={activeTab}
|
|
||||||
setActiveTab={setActiveTab}
|
|
||||||
closeTab={closeTab}
|
|
||||||
onHomeClick={onHomeClick}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -257,14 +257,14 @@ export function FileManagerContextMenu({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download function - unified download that uses best available method
|
// Download function - use proper download handler
|
||||||
if (hasFiles && onDragToDesktop) {
|
if (hasFiles && onDownload) {
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
icon: <Download className="w-4 h-4" />,
|
icon: <Download className="w-4 h-4" />,
|
||||||
label: isMultipleFiles
|
label: isMultipleFiles
|
||||||
? t("fileManager.downloadFiles", { count: files.length })
|
? t("fileManager.downloadFiles", { count: files.length })
|
||||||
: t("fileManager.downloadFile"),
|
: t("fileManager.downloadFile"),
|
||||||
action: () => onDragToDesktop(),
|
action: () => onDownload(files),
|
||||||
shortcut: "Ctrl+D",
|
shortcut: "Ctrl+D",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,348 +0,0 @@
|
|||||||
import React, { 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 FileManagerCodeEditorProps {
|
|
||||||
content: string;
|
|
||||||
fileName: string;
|
|
||||||
onContentChange: (value: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FileManagerFileEditor({
|
|
||||||
content,
|
|
||||||
fileName,
|
|
||||||
onContentChange,
|
|
||||||
}: FileManagerCodeEditorProps) {
|
|
||||||
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 className="w-full h-full relative overflow-hidden flex flex-col">
|
|
||||||
<div className="w-full h-full overflow-auto flex-1 flex flex-col config-codemirror-scroll-wrapper">
|
|
||||||
<CodeMirror
|
|
||||||
value={content}
|
|
||||||
extensions={[
|
|
||||||
loadLanguage(getLanguageName(fileName || "untitled.txt") as any) ||
|
|
||||||
[],
|
|
||||||
hyperLink,
|
|
||||||
oneDark,
|
|
||||||
EditorView.theme({
|
|
||||||
"&": {
|
|
||||||
backgroundColor: "var(--color-dark-bg-darkest) !important",
|
|
||||||
height: "100%",
|
|
||||||
},
|
|
||||||
".cm-gutters": {
|
|
||||||
backgroundColor: "var(--color-dark-bg) !important",
|
|
||||||
},
|
|
||||||
".cm-scroller": {
|
|
||||||
overflow: "auto",
|
|
||||||
},
|
|
||||||
".cm-editor": {
|
|
||||||
height: "100%",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]}
|
|
||||||
onChange={(value: any) => onContentChange(value)}
|
|
||||||
theme={undefined}
|
|
||||||
height="100%"
|
|
||||||
basicSetup={{
|
|
||||||
lineNumbers: true,
|
|
||||||
scrollPastEnd: false,
|
|
||||||
}}
|
|
||||||
className="min-h-full min-w-full flex-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useRef, useCallback, useEffect } from "react";
|
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
Folder,
|
Folder,
|
||||||
@@ -60,6 +61,7 @@ function formatFileSize(bytes?: number): string {
|
|||||||
interface DragState {
|
interface DragState {
|
||||||
type: "none" | "internal" | "external";
|
type: "none" | "internal" | "external";
|
||||||
files: FileItem[];
|
files: FileItem[];
|
||||||
|
draggedFiles?: FileItem[];
|
||||||
target?: FileItem;
|
target?: FileItem;
|
||||||
counter: number;
|
counter: number;
|
||||||
mousePosition?: { x: number; y: number };
|
mousePosition?: { x: number; y: number };
|
||||||
@@ -91,7 +93,7 @@ interface FileManagerGridProps {
|
|||||||
onFileDrop?: (draggedFiles: FileItem[], targetFile: FileItem) => void;
|
onFileDrop?: (draggedFiles: FileItem[], targetFile: FileItem) => void;
|
||||||
onFileDiff?: (file1: FileItem, file2: FileItem) => void;
|
onFileDiff?: (file1: FileItem, file2: FileItem) => void;
|
||||||
onSystemDragStart?: (files: FileItem[]) => void;
|
onSystemDragStart?: (files: FileItem[]) => void;
|
||||||
onSystemDragEnd?: (e: DragEvent) => void;
|
onSystemDragEnd?: (e: DragEvent, files: FileItem[]) => void;
|
||||||
hasClipboard?: boolean;
|
hasClipboard?: boolean;
|
||||||
// Linus-style creation intent props
|
// Linus-style creation intent props
|
||||||
createIntent?: CreateIntent | null;
|
createIntent?: CreateIntent | null;
|
||||||
@@ -283,6 +285,7 @@ export function FileManagerGrid({
|
|||||||
setDragState({
|
setDragState({
|
||||||
type: "internal",
|
type: "internal",
|
||||||
files: filesToDrag,
|
files: filesToDrag,
|
||||||
|
draggedFiles: filesToDrag,
|
||||||
counter: 0,
|
counter: 0,
|
||||||
mousePosition: { x: e.clientX, y: e.clientY },
|
mousePosition: { x: e.clientX, y: e.clientY },
|
||||||
});
|
});
|
||||||
@@ -293,9 +296,6 @@ export function FileManagerGrid({
|
|||||||
files: filesToDrag.map((f) => f.path),
|
files: filesToDrag.map((f) => f.path),
|
||||||
};
|
};
|
||||||
e.dataTransfer.setData("text/plain", JSON.stringify(dragData));
|
e.dataTransfer.setData("text/plain", JSON.stringify(dragData));
|
||||||
|
|
||||||
// Trigger system-level drag start
|
|
||||||
onSystemDragStart?.(filesToDrag);
|
|
||||||
e.dataTransfer.effectAllowed = "move";
|
e.dataTransfer.effectAllowed = "move";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -378,10 +378,11 @@ export function FileManagerGrid({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleFileDragEnd = (e: React.DragEvent) => {
|
const handleFileDragEnd = (e: React.DragEvent) => {
|
||||||
|
const draggedFiles = dragState.draggedFiles || [];
|
||||||
setDragState({ type: "none", files: [], counter: 0 });
|
setDragState({ type: "none", files: [], counter: 0 });
|
||||||
|
|
||||||
// Trigger system-level drag end detection
|
// Trigger system-level drag end detection with dragged files
|
||||||
onSystemDragEnd?.(e.nativeEvent);
|
onSystemDragEnd?.(e.nativeEvent, draggedFiles);
|
||||||
};
|
};
|
||||||
|
|
||||||
const [isSelecting, setIsSelecting] = useState(false);
|
const [isSelecting, setIsSelecting] = useState(false);
|
||||||
@@ -1356,44 +1357,50 @@ export function FileManagerGrid({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Drag following tooltip */}
|
{/* Drag following tooltip - rendered as portal to ensure highest z-index */}
|
||||||
{dragState.type === "internal" &&
|
{dragState.type === "internal" &&
|
||||||
dragState.files.length > 0 &&
|
(dragState.files.length > 0 || dragState.draggedFiles?.length > 0) &&
|
||||||
dragState.mousePosition && (
|
dragState.mousePosition &&
|
||||||
|
createPortal(
|
||||||
<div
|
<div
|
||||||
className="fixed z-[99999] pointer-events-none"
|
className="fixed pointer-events-none"
|
||||||
style={{
|
style={{
|
||||||
left: dragState.mousePosition.x + 24,
|
left: Math.min(Math.max(dragState.mousePosition.x + 40, 10), window.innerWidth - 300),
|
||||||
top: dragState.mousePosition.y - 40,
|
top: Math.max(Math.min(dragState.mousePosition.y - 80, window.innerHeight - 100), 10),
|
||||||
|
zIndex: 999999,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="bg-background border border-border rounded-md shadow-md px-3 py-2 flex items-center gap-2">
|
<div className="bg-background border border-border rounded-md shadow-md px-3 py-2 flex items-center gap-2">
|
||||||
{dragState.target ? (
|
{(() => {
|
||||||
dragState.target.type === "directory" ? (
|
const files = dragState.files.length > 0 ? dragState.files : dragState.draggedFiles || [];
|
||||||
<>
|
return dragState.target ? (
|
||||||
<Move className="w-4 h-4 text-blue-500" />
|
dragState.target.type === "directory" ? (
|
||||||
<span className="text-sm font-medium text-foreground">
|
<>
|
||||||
Move to {dragState.target.name}
|
<Move className="w-4 h-4 text-blue-500" />
|
||||||
</span>
|
<span className="text-sm font-medium text-foreground">
|
||||||
</>
|
Move to {dragState.target.name}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<GitCompare className="w-4 h-4 text-purple-500" />
|
||||||
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
Diff compare with {dragState.target.name}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<GitCompare className="w-4 h-4 text-purple-500" />
|
<Download className="w-4 h-4 text-green-500" />
|
||||||
<span className="text-sm font-medium text-foreground">
|
<span className="text-sm font-medium text-foreground">
|
||||||
Diff compare with {dragState.target.name}
|
Drag outside window to download ({files.length} files)
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
) : (
|
})()}
|
||||||
<>
|
|
||||||
<Download className="w-4 h-4 text-green-500" />
|
|
||||||
<span className="text-sm font-medium text-foreground">
|
|
||||||
Drag outside window to download ({dragState.files.length} files)
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,234 +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";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import type { FileItem, ShortcutItem } from "../../../types/index";
|
|
||||||
|
|
||||||
interface FileManagerHomeViewProps {
|
|
||||||
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 FileManagerHomeView({
|
|
||||||
recent,
|
|
||||||
pinned,
|
|
||||||
shortcuts,
|
|
||||||
onOpenFile,
|
|
||||||
onRemoveRecent,
|
|
||||||
onPinFile,
|
|
||||||
onUnpinFile,
|
|
||||||
onOpenShortcut,
|
|
||||||
onRemoveShortcut,
|
|
||||||
onAddShortcut,
|
|
||||||
}: FileManagerHomeViewProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
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-dark-bg border-2 border-dark-border rounded hover:border-dark-border-hover 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-dark-bg-button hover:bg-dark-hover 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-dark-bg-button hover:bg-dark-hover 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-dark-bg border-2 border-dark-border rounded hover:border-dark-border-hover 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-dark-bg-button hover:bg-dark-hover 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-dark-bg-darkest">
|
|
||||||
<Tabs
|
|
||||||
value={tab}
|
|
||||||
onValueChange={(v) => setTab(v as "recent" | "pinned" | "shortcuts")}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<TabsList className="mb-4 bg-dark-bg border-2 border-dark-border">
|
|
||||||
<TabsTrigger
|
|
||||||
value="recent"
|
|
||||||
className="data-[state=active]:bg-dark-bg-button"
|
|
||||||
>
|
|
||||||
{t("fileManager.recent")}
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="pinned"
|
|
||||||
className="data-[state=active]:bg-dark-bg-button"
|
|
||||||
>
|
|
||||||
{t("fileManager.pinned")}
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="shortcuts"
|
|
||||||
className="data-[state=active]:bg-dark-bg-button"
|
|
||||||
>
|
|
||||||
{t("fileManager.folderShortcuts")}
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="recent" className="mt-0">
|
|
||||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
|
||||||
{recent.length === 0 ? (
|
|
||||||
<div className="flex items-center justify-center py-8 col-span-full">
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{t("fileManager.noRecentFiles")}
|
|
||||||
</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-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
|
||||||
{pinned.length === 0 ? (
|
|
||||||
<div className="flex items-center justify-center py-8 col-span-full">
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{t("fileManager.noPinnedFiles")}
|
|
||||||
</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-dark-bg border-2 border-dark-border rounded-lg">
|
|
||||||
<Input
|
|
||||||
placeholder={t("fileManager.enterFolderPath")}
|
|
||||||
value={newShortcut}
|
|
||||||
onChange={(e) => setNewShortcut(e.target.value)}
|
|
||||||
className="flex-1 bg-dark-bg-button border-2 border-dark-border text-white placeholder:text-muted-foreground"
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" && newShortcut.trim()) {
|
|
||||||
onAddShortcut(newShortcut.trim());
|
|
||||||
setNewShortcut("");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-8 px-2 bg-dark-bg-button border-2 !border-dark-border hover:bg-dark-hover rounded-md"
|
|
||||||
onClick={() => {
|
|
||||||
if (newShortcut.trim()) {
|
|
||||||
onAddShortcut(newShortcut.trim());
|
|
||||||
setNewShortcut("");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="w-3.5 h-3.5 mr-1" />
|
|
||||||
{t("common.add")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
|
||||||
{shortcuts.length === 0 ? (
|
|
||||||
<div className="flex items-center justify-center py-4 col-span-full">
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{t("fileManager.noShortcuts")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
shortcuts.map((shortcut) => renderShortcutCard(shortcut))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,709 +0,0 @@
|
|||||||
import React, {
|
|
||||||
useEffect,
|
|
||||||
useState,
|
|
||||||
useRef,
|
|
||||||
forwardRef,
|
|
||||||
useImperativeHandle,
|
|
||||||
} from "react";
|
|
||||||
import {
|
|
||||||
Folder,
|
|
||||||
File,
|
|
||||||
FileSymlink,
|
|
||||||
ArrowUp,
|
|
||||||
Pin,
|
|
||||||
MoreVertical,
|
|
||||||
Trash2,
|
|
||||||
Edit3,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area.tsx";
|
|
||||||
import { cn } from "@/lib/utils.ts";
|
|
||||||
import { Input } from "@/components/ui/input.tsx";
|
|
||||||
import { Button } from "@/components/ui/button.tsx";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
listSSHFiles,
|
|
||||||
renameSSHItem,
|
|
||||||
deleteSSHItem,
|
|
||||||
getFileManagerPinned,
|
|
||||||
addFileManagerPinned,
|
|
||||||
removeFileManagerPinned,
|
|
||||||
getSSHStatus,
|
|
||||||
connectSSH,
|
|
||||||
identifySSHSymlink,
|
|
||||||
} from "@/ui/main-axios.ts";
|
|
||||||
import type { SSHHost } from "../../../types/index.js";
|
|
||||||
|
|
||||||
const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
|
||||||
{
|
|
||||||
onOpenFile,
|
|
||||||
tabs,
|
|
||||||
host,
|
|
||||||
onOperationComplete,
|
|
||||||
onPathChange,
|
|
||||||
onDeleteItem,
|
|
||||||
}: {
|
|
||||||
onSelectView?: (view: string) => void;
|
|
||||||
onOpenFile: (file: any) => void;
|
|
||||||
tabs: any[];
|
|
||||||
host: SSHHost;
|
|
||||||
onOperationComplete?: () => void;
|
|
||||||
onError?: (error: string) => void;
|
|
||||||
onSuccess?: (message: string) => void;
|
|
||||||
onPathChange?: (path: string) => void;
|
|
||||||
onDeleteItem?: (item: any) => void;
|
|
||||||
},
|
|
||||||
ref,
|
|
||||||
) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [currentPath, setCurrentPath] = useState("/");
|
|
||||||
const [files, setFiles] = useState<any[]>([]);
|
|
||||||
const pathInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
|
||||||
const [fileSearch, setFileSearch] = useState("");
|
|
||||||
const [debouncedFileSearch, setDebouncedFileSearch] = useState("");
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = setTimeout(() => setDebouncedSearch(search), 200);
|
|
||||||
return () => clearTimeout(handler);
|
|
||||||
}, [search]);
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = setTimeout(() => setDebouncedSearch(fileSearch), 200);
|
|
||||||
return () => clearTimeout(handler);
|
|
||||||
}, [fileSearch]);
|
|
||||||
|
|
||||||
const [sshSessionId, setSshSessionId] = useState<string | null>(null);
|
|
||||||
const [filesLoading, setFilesLoading] = useState(false);
|
|
||||||
const [connectingSSH, setConnectingSSH] = useState(false);
|
|
||||||
const [connectionCache, setConnectionCache] = useState<
|
|
||||||
Record<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
sessionId: string;
|
|
||||||
timestamp: number;
|
|
||||||
}
|
|
||||||
>
|
|
||||||
>({});
|
|
||||||
const [fetchingFiles, setFetchingFiles] = useState(false);
|
|
||||||
|
|
||||||
const [contextMenu, setContextMenu] = useState<{
|
|
||||||
visible: boolean;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
item: any;
|
|
||||||
}>({
|
|
||||||
visible: false,
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
item: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [renamingItem, setRenamingItem] = useState<{
|
|
||||||
item: any;
|
|
||||||
newName: string;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const nextPath = host?.defaultPath || "/";
|
|
||||||
setCurrentPath(nextPath);
|
|
||||||
onPathChange?.(nextPath);
|
|
||||||
(async () => {
|
|
||||||
await connectToSSH(host);
|
|
||||||
})();
|
|
||||||
}, [host?.id]);
|
|
||||||
|
|
||||||
async function connectToSSH(server: SSHHost): Promise<string | null> {
|
|
||||||
const sessionId = server.id.toString();
|
|
||||||
|
|
||||||
const cached = connectionCache[sessionId];
|
|
||||||
if (cached && Date.now() - cached.timestamp < 30000) {
|
|
||||||
setSshSessionId(cached.sessionId);
|
|
||||||
return cached.sessionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (connectingSSH) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
setConnectingSSH(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!server.password && !server.key) {
|
|
||||||
toast.error(t("common.noAuthCredentials"));
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const connectionConfig = {
|
|
||||||
hostId: server.id,
|
|
||||||
ip: server.ip,
|
|
||||||
port: server.port,
|
|
||||||
username: server.username,
|
|
||||||
password: server.password,
|
|
||||||
sshKey: server.key,
|
|
||||||
keyPassword: server.keyPassword,
|
|
||||||
authType: server.authType,
|
|
||||||
credentialId: server.credentialId,
|
|
||||||
userId: server.userId,
|
|
||||||
};
|
|
||||||
|
|
||||||
await connectSSH(sessionId, connectionConfig);
|
|
||||||
|
|
||||||
setSshSessionId(sessionId);
|
|
||||||
|
|
||||||
setConnectionCache((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[sessionId]: { sessionId, timestamp: Date.now() },
|
|
||||||
}));
|
|
||||||
|
|
||||||
return sessionId;
|
|
||||||
} catch (err: any) {
|
|
||||||
toast.error(
|
|
||||||
err?.response?.data?.error || t("fileManager.failedToConnectSSH"),
|
|
||||||
);
|
|
||||||
setSshSessionId(null);
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
setConnectingSSH(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchFiles() {
|
|
||||||
if (fetchingFiles) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setFetchingFiles(true);
|
|
||||||
setFiles([]);
|
|
||||||
setFilesLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
let pinnedFiles: any[] = [];
|
|
||||||
try {
|
|
||||||
if (host) {
|
|
||||||
pinnedFiles = await getFileManagerPinned(host.id);
|
|
||||||
}
|
|
||||||
} catch (err) {}
|
|
||||||
|
|
||||||
if (host && sshSessionId) {
|
|
||||||
let res: any[] = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const status = await getSSHStatus(sshSessionId);
|
|
||||||
if (!status.connected) {
|
|
||||||
const newSessionId = await connectToSSH(host);
|
|
||||||
if (newSessionId) {
|
|
||||||
setSshSessionId(newSessionId);
|
|
||||||
res = await listSSHFiles(newSessionId, currentPath);
|
|
||||||
} else {
|
|
||||||
throw new Error(t("fileManager.failedToReconnectSSH"));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
res = await listSSHFiles(sshSessionId, currentPath);
|
|
||||||
}
|
|
||||||
} catch (sessionErr) {
|
|
||||||
const newSessionId = await connectToSSH(host);
|
|
||||||
if (newSessionId) {
|
|
||||||
setSshSessionId(newSessionId);
|
|
||||||
res = await listSSHFiles(newSessionId, currentPath);
|
|
||||||
} else {
|
|
||||||
throw sessionErr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const processedFiles = (res || []).map((f: any) => {
|
|
||||||
const filePath =
|
|
||||||
currentPath + (currentPath.endsWith("/") ? "" : "/") + f.name;
|
|
||||||
const isPinned = pinnedFiles.some(
|
|
||||||
(pinned) => pinned.path === filePath,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
...f,
|
|
||||||
path: filePath,
|
|
||||||
isPinned,
|
|
||||||
isSSH: true,
|
|
||||||
sshSessionId: sshSessionId,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
setFiles(processedFiles);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
setFiles([]);
|
|
||||||
toast.error(
|
|
||||||
err?.response?.data?.error ||
|
|
||||||
err?.message ||
|
|
||||||
t("fileManager.failedToListFiles"),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setFilesLoading(false);
|
|
||||||
setFetchingFiles(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (host && sshSessionId && !connectingSSH && !fetchingFiles) {
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
fetchFiles();
|
|
||||||
}, 100);
|
|
||||||
return () => clearTimeout(timeoutId);
|
|
||||||
}
|
|
||||||
}, [currentPath, host, sshSessionId]);
|
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
|
||||||
openFolder: async (_server: SSHHost, path: string) => {
|
|
||||||
if (connectingSSH || fetchingFiles) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentPath === path) {
|
|
||||||
setTimeout(() => fetchFiles(), 100);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setFetchingFiles(false);
|
|
||||||
setFilesLoading(false);
|
|
||||||
setFiles([]);
|
|
||||||
|
|
||||||
setCurrentPath(path);
|
|
||||||
onPathChange?.(path);
|
|
||||||
if (!sshSessionId) {
|
|
||||||
const sessionId = await connectToSSH(host);
|
|
||||||
if (sessionId) setSshSessionId(sessionId);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
fetchFiles: () => {
|
|
||||||
if (host && sshSessionId) {
|
|
||||||
fetchFiles();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
getCurrentPath: () => currentPath,
|
|
||||||
}));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (pathInputRef.current) {
|
|
||||||
pathInputRef.current.scrollLeft = pathInputRef.current.scrollWidth;
|
|
||||||
}
|
|
||||||
}, [currentPath]);
|
|
||||||
|
|
||||||
const filteredFiles = files.filter((file) => {
|
|
||||||
const q = debouncedFileSearch.trim().toLowerCase();
|
|
||||||
if (!q) return true;
|
|
||||||
return file.name.toLowerCase().includes(q);
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleContextMenu = (e: React.MouseEvent, item: any) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const viewportWidth = window.innerWidth;
|
|
||||||
const viewportHeight = window.innerHeight;
|
|
||||||
|
|
||||||
const menuWidth = 160;
|
|
||||||
const menuHeight = 80;
|
|
||||||
|
|
||||||
let x = e.clientX;
|
|
||||||
let y = e.clientY;
|
|
||||||
|
|
||||||
if (x + menuWidth > viewportWidth) {
|
|
||||||
x = e.clientX - menuWidth;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (y + menuHeight > viewportHeight) {
|
|
||||||
y = e.clientY - menuHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (x < 0) {
|
|
||||||
x = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (y < 0) {
|
|
||||||
y = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
setContextMenu({
|
|
||||||
visible: true,
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
item,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeContextMenu = () => {
|
|
||||||
setContextMenu({ visible: false, x: 0, y: 0, item: null });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRename = async (item: any, newName: string) => {
|
|
||||||
if (!sshSessionId || !newName.trim() || newName === item.name) {
|
|
||||||
setRenamingItem(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await renameSSHItem(sshSessionId, item.path, newName.trim());
|
|
||||||
toast.success(
|
|
||||||
`${item.type === "directory" ? t("common.folder") : item.type === "link" ? t("common.link") : t("common.file")} ${t("common.renamedSuccessfully")}`,
|
|
||||||
);
|
|
||||||
setRenamingItem(null);
|
|
||||||
if (onOperationComplete) {
|
|
||||||
onOperationComplete();
|
|
||||||
} else {
|
|
||||||
fetchFiles();
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(
|
|
||||||
error?.response?.data?.error || t("fileManager.failedToRenameItem"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const startRename = (item: any) => {
|
|
||||||
setRenamingItem({ item, newName: item.name });
|
|
||||||
closeContextMenu();
|
|
||||||
};
|
|
||||||
|
|
||||||
const startDelete = (item: any) => {
|
|
||||||
onDeleteItem?.(item);
|
|
||||||
closeContextMenu();
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = () => closeContextMenu();
|
|
||||||
document.addEventListener("click", handleClickOutside);
|
|
||||||
return () => document.removeEventListener("click", handleClickOutside);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handlePathChange = (newPath: string) => {
|
|
||||||
setCurrentPath(newPath);
|
|
||||||
onPathChange?.(newPath);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle symlink resolution
|
|
||||||
const handleSymlinkClick = async (item: any) => {
|
|
||||||
if (!host) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Extract just the symlink path (before the " -> " if present)
|
|
||||||
const symlinkPath = item.path.includes(" -> ")
|
|
||||||
? item.path.split(" -> ")[0]
|
|
||||||
: item.path;
|
|
||||||
|
|
||||||
let currentSessionId = sshSessionId;
|
|
||||||
|
|
||||||
// Check SSH connection status and reconnect if needed
|
|
||||||
if (currentSessionId) {
|
|
||||||
try {
|
|
||||||
const status = await getSSHStatus(currentSessionId);
|
|
||||||
if (!status.connected) {
|
|
||||||
const newSessionId = await connectToSSH(host);
|
|
||||||
if (newSessionId) {
|
|
||||||
setSshSessionId(newSessionId);
|
|
||||||
currentSessionId = newSessionId;
|
|
||||||
} else {
|
|
||||||
throw new Error(t("fileManager.failedToReconnectSSH"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (sessionErr) {
|
|
||||||
const newSessionId = await connectToSSH(host);
|
|
||||||
if (newSessionId) {
|
|
||||||
setSshSessionId(newSessionId);
|
|
||||||
currentSessionId = newSessionId;
|
|
||||||
} else {
|
|
||||||
throw sessionErr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No session ID, try to connect
|
|
||||||
const newSessionId = await connectToSSH(host);
|
|
||||||
if (newSessionId) {
|
|
||||||
setSshSessionId(newSessionId);
|
|
||||||
currentSessionId = newSessionId;
|
|
||||||
} else {
|
|
||||||
throw new Error(t("fileManager.failedToConnectSSH"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const symlinkInfo = await identifySSHSymlink(
|
|
||||||
currentSessionId,
|
|
||||||
symlinkPath,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (symlinkInfo.type === "directory") {
|
|
||||||
// If symlink points to a directory, navigate to it
|
|
||||||
handlePathChange(symlinkInfo.target);
|
|
||||||
} else if (symlinkInfo.type === "file") {
|
|
||||||
// If symlink points to a file, open it as a file
|
|
||||||
onOpenFile({
|
|
||||||
name: item.name,
|
|
||||||
path: symlinkInfo.target, // Use the target path, not the symlink path
|
|
||||||
isSSH: item.isSSH,
|
|
||||||
sshSessionId: currentSessionId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(
|
|
||||||
error?.response?.data?.error ||
|
|
||||||
error?.message ||
|
|
||||||
t("fileManager.failedToResolveSymlink"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full w-[256px] max-w-[256px]">
|
|
||||||
<div className="flex flex-col flex-grow min-h-0">
|
|
||||||
<div className="flex-1 w-full h-full flex flex-col bg-dark-bg-darkest border-r-2 border-dark-border overflow-hidden p-0 relative min-h-0">
|
|
||||||
{host && (
|
|
||||||
<div className="flex flex-col h-full w-full max-w-[260px]">
|
|
||||||
<div className="flex items-center gap-2 px-2 py-1.5 border-b-2 border-dark-border bg-dark-bg z-20 max-w-[260px]">
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="outline"
|
|
||||||
className="h-9 w-9 bg-dark-bg border-2 border-dark-border rounded-md hover:bg-dark-hover focus:outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
onClick={() => {
|
|
||||||
let path = currentPath;
|
|
||||||
if (path && path !== "/" && path !== "") {
|
|
||||||
if (path.endsWith("/")) path = path.slice(0, -1);
|
|
||||||
const lastSlash = path.lastIndexOf("/");
|
|
||||||
if (lastSlash > 0) {
|
|
||||||
handlePathChange(path.slice(0, lastSlash));
|
|
||||||
} else {
|
|
||||||
handlePathChange("/");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
handlePathChange("/");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ArrowUp className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Input
|
|
||||||
ref={pathInputRef}
|
|
||||||
value={currentPath}
|
|
||||||
onChange={(e) => handlePathChange(e.target.value)}
|
|
||||||
className="flex-1 bg-dark-bg border-2 border-dark-border-hover text-white truncate rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring hover:border-dark-border-light"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="px-2 py-2 border-b-1 border-dark-border bg-dark-bg">
|
|
||||||
<Input
|
|
||||||
placeholder={t("fileManager.searchFilesAndFolders")}
|
|
||||||
className="w-full h-7 text-sm bg-dark-bg-button border-2 border-dark-border-hover text-white placeholder:text-muted-foreground rounded-md"
|
|
||||||
autoComplete="off"
|
|
||||||
value={fileSearch}
|
|
||||||
onChange={(e) => setFileSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-h-0 w-full bg-dark-bg-darkest border-t-1 border-dark-border">
|
|
||||||
<ScrollArea className="h-full w-full bg-dark-bg-darkest">
|
|
||||||
<div className="p-2 pb-0">
|
|
||||||
{connectingSSH || filesLoading ? (
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{t("common.loading")}
|
|
||||||
</div>
|
|
||||||
) : filteredFiles.length === 0 ? (
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{t("fileManager.noFilesOrFoldersFound")}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{filteredFiles.map((item: any) => {
|
|
||||||
const isOpen = (tabs || []).some(
|
|
||||||
(t: any) => t.id === item.path,
|
|
||||||
);
|
|
||||||
const isRenaming =
|
|
||||||
renamingItem?.item?.path === item.path;
|
|
||||||
const isDeleting = false;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={item.path}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 px-3 py-2 bg-dark-bg border-2 border-dark-border rounded group max-w-[220px] mb-2 relative",
|
|
||||||
isOpen &&
|
|
||||||
"opacity-60 cursor-not-allowed pointer-events-none",
|
|
||||||
)}
|
|
||||||
onContextMenu={(e) =>
|
|
||||||
!isOpen && handleContextMenu(e, item)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isRenaming ? (
|
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
|
||||||
{item.type === "directory" ? (
|
|
||||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
|
|
||||||
) : item.type === "link" ? (
|
|
||||||
<FileSymlink className="w-4 h-4 text-blue-400 flex-shrink-0" />
|
|
||||||
) : (
|
|
||||||
<File className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
|
||||||
)}
|
|
||||||
<Input
|
|
||||||
value={renamingItem.newName}
|
|
||||||
onChange={(e) =>
|
|
||||||
setRenamingItem((prev) =>
|
|
||||||
prev
|
|
||||||
? {
|
|
||||||
...prev,
|
|
||||||
newName: e.target.value,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="flex-1 h-6 text-sm bg-dark-bg-button border border-dark-border-hover text-white"
|
|
||||||
autoFocus
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
handleRename(
|
|
||||||
item,
|
|
||||||
renamingItem.newName,
|
|
||||||
);
|
|
||||||
} else if (e.key === "Escape") {
|
|
||||||
setRenamingItem(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onBlur={() =>
|
|
||||||
handleRename(item, renamingItem.newName)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
|
|
||||||
onClick={() =>
|
|
||||||
!isOpen &&
|
|
||||||
(item.type === "directory"
|
|
||||||
? handlePathChange(item.path)
|
|
||||||
: item.type === "link"
|
|
||||||
? handleSymlinkClick(item)
|
|
||||||
: onOpenFile({
|
|
||||||
name: item.name,
|
|
||||||
path: item.path,
|
|
||||||
isSSH: item.isSSH,
|
|
||||||
sshSessionId: item.sshSessionId,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{item.type === "directory" ? (
|
|
||||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
|
|
||||||
) : item.type === "link" ? (
|
|
||||||
<FileSymlink className="w-4 h-4 text-blue-400 flex-shrink-0" />
|
|
||||||
) : (
|
|
||||||
<File className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
|
||||||
)}
|
|
||||||
<span className="text-sm text-white truncate flex-1 min-w-0">
|
|
||||||
{item.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{item.type === "file" && (
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-7 w-7"
|
|
||||||
disabled={isOpen}
|
|
||||||
onClick={async (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
try {
|
|
||||||
if (item.isPinned) {
|
|
||||||
await removeFileManagerPinned({
|
|
||||||
name: item.name,
|
|
||||||
path: item.path,
|
|
||||||
hostId: host?.id,
|
|
||||||
isSSH: true,
|
|
||||||
sshSessionId:
|
|
||||||
host?.id.toString(),
|
|
||||||
});
|
|
||||||
setFiles(
|
|
||||||
files.map((f) =>
|
|
||||||
f.path === item.path
|
|
||||||
? {
|
|
||||||
...f,
|
|
||||||
isPinned: false,
|
|
||||||
}
|
|
||||||
: f,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await addFileManagerPinned({
|
|
||||||
name: item.name,
|
|
||||||
path: item.path,
|
|
||||||
hostId: host?.id,
|
|
||||||
isSSH: true,
|
|
||||||
sshSessionId:
|
|
||||||
host?.id.toString(),
|
|
||||||
});
|
|
||||||
setFiles(
|
|
||||||
files.map((f) =>
|
|
||||||
f.path === item.path
|
|
||||||
? {
|
|
||||||
...f,
|
|
||||||
isPinned: true,
|
|
||||||
}
|
|
||||||
: f,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pin
|
|
||||||
className={`w-1 h-1 ${item.isPinned ? "text-yellow-400 fill-current" : "text-muted-foreground"}`}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{!isOpen && (
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleContextMenu(e, item);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MoreVertical className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{contextMenu.visible && contextMenu.item && (
|
|
||||||
<div
|
|
||||||
className="fixed z-[99998] bg-dark-bg border-2 border-dark-border rounded-lg shadow-xl py-1 min-w-[160px]"
|
|
||||||
style={{
|
|
||||||
left: contextMenu.x,
|
|
||||||
top: contextMenu.y,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
className="w-full px-3 py-2 text-left text-sm text-white hover:bg-dark-hover flex items-center gap-2"
|
|
||||||
onClick={() => startRename(contextMenu.item)}
|
|
||||||
>
|
|
||||||
<Edit3 className="w-4 h-4" />
|
|
||||||
Rename
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="w-full px-3 py-2 text-left text-sm text-red-400 hover:bg-dark-hover flex items-center gap-2"
|
|
||||||
onClick={() => startDelete(contextMenu.item)}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export { FileManagerLeftSidebar };
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Button } from "@/components/ui/button.tsx";
|
|
||||||
import { Card } from "@/components/ui/card.tsx";
|
|
||||||
import { Folder, File, Trash2, Pin, Download } from "lucide-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
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 FileManagerLeftSidebarVileViewerProps {
|
|
||||||
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;
|
|
||||||
onDownloadFile?: (file: FileItem) => void;
|
|
||||||
onDeleteFile: (file: FileItem) => void;
|
|
||||||
isLoading?: boolean;
|
|
||||||
error?: string;
|
|
||||||
isSSHMode: boolean;
|
|
||||||
onSwitchToLocal: () => void;
|
|
||||||
onSwitchToSSH: (conn: SSHConnection) => void;
|
|
||||||
currentSSH?: SSHConnection;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FileManagerLeftSidebarFileViewer({
|
|
||||||
currentPath,
|
|
||||||
files,
|
|
||||||
onOpenFile,
|
|
||||||
onOpenFolder,
|
|
||||||
onStarFile,
|
|
||||||
onDownloadFile,
|
|
||||||
onDeleteFile,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
isSSHMode,
|
|
||||||
}: FileManagerLeftSidebarVileViewerProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<div className="flex-1 bg-dark-bg-darkest p-2 overflow-y-auto">
|
|
||||||
<div className="mb-2 flex items-center gap-2">
|
|
||||||
<span className="text-xs text-muted-foreground font-semibold">
|
|
||||||
{isSSHMode ? t("common.sshPath") : t("common.localPath")}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-white truncate">{currentPath}</span>
|
|
||||||
</div>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{t("common.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-dark-bg border-2 border-dark-border 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>
|
|
||||||
{item.type === "file" && onDownloadFile && (
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-7 w-7"
|
|
||||||
onClick={() => onDownloadFile(item)}
|
|
||||||
title={t("fileManager.downloadFile")}
|
|
||||||
>
|
|
||||||
<Download className="w-4 h-4 text-blue-400" />
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,980 +0,0 @@
|
|||||||
import React, { useState, useRef, useEffect } from "react";
|
|
||||||
import { Button } from "@/components/ui/button.tsx";
|
|
||||||
import { Input } from "@/components/ui/input.tsx";
|
|
||||||
import { Card } from "@/components/ui/card.tsx";
|
|
||||||
import { Separator } from "@/components/ui/separator.tsx";
|
|
||||||
import {
|
|
||||||
Upload,
|
|
||||||
Download,
|
|
||||||
FilePlus,
|
|
||||||
FolderPlus,
|
|
||||||
Trash2,
|
|
||||||
Edit3,
|
|
||||||
X,
|
|
||||||
AlertCircle,
|
|
||||||
FileText,
|
|
||||||
Folder,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { cn } from "@/lib/utils.ts";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import type { FileManagerOperationsProps } from "../../../types/index.js";
|
|
||||||
|
|
||||||
export function FileManagerOperations({
|
|
||||||
currentPath,
|
|
||||||
sshSessionId,
|
|
||||||
onOperationComplete,
|
|
||||||
onError,
|
|
||||||
onSuccess,
|
|
||||||
}: FileManagerOperationsProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [showUpload, setShowUpload] = useState(false);
|
|
||||||
const [showDownload, setShowDownload] = useState(false);
|
|
||||||
const [showCreateFile, setShowCreateFile] = useState(false);
|
|
||||||
const [showCreateFolder, setShowCreateFolder] = useState(false);
|
|
||||||
const [showDelete, setShowDelete] = useState(false);
|
|
||||||
const [showRename, setShowRename] = useState(false);
|
|
||||||
|
|
||||||
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
|
||||||
const [downloadPath, setDownloadPath] = useState("");
|
|
||||||
const [newFileName, setNewFileName] = useState("");
|
|
||||||
const [newFolderName, setNewFolderName] = useState("");
|
|
||||||
const [deletePath, setDeletePath] = useState("");
|
|
||||||
const [deleteIsDirectory, setDeleteIsDirectory] = useState(false);
|
|
||||||
const [renamePath, setRenamePath] = useState("");
|
|
||||||
const [renameIsDirectory, setRenameIsDirectory] = useState(false);
|
|
||||||
const [newName, setNewName] = useState("");
|
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [showTextLabels, setShowTextLabels] = useState(true);
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const checkContainerWidth = () => {
|
|
||||||
if (containerRef.current) {
|
|
||||||
const width = containerRef.current.offsetWidth;
|
|
||||||
setShowTextLabels(width > 240);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
checkContainerWidth();
|
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(checkContainerWidth);
|
|
||||||
if (containerRef.current) {
|
|
||||||
resizeObserver.observe(containerRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
resizeObserver.disconnect();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleFileUpload = async () => {
|
|
||||||
if (!uploadFile || !sshSessionId) return;
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
const { toast } = await import("sonner");
|
|
||||||
const loadingToast = toast.loading(
|
|
||||||
t("fileManager.uploadingFile", { name: uploadFile.name }),
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Read file content - support text and binary files
|
|
||||||
const content = await new Promise<string>((resolve, reject) => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onerror = () => reject(reader.error);
|
|
||||||
|
|
||||||
// Check file type to determine reading method
|
|
||||||
const isTextFile =
|
|
||||||
uploadFile.type.startsWith("text/") ||
|
|
||||||
uploadFile.type === "application/json" ||
|
|
||||||
uploadFile.type === "application/javascript" ||
|
|
||||||
uploadFile.type === "application/xml" ||
|
|
||||||
uploadFile.name.match(
|
|
||||||
/\.(txt|json|js|ts|jsx|tsx|css|html|htm|xml|yaml|yml|md|py|java|c|cpp|h|sh|bat|ps1)$/i,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isTextFile) {
|
|
||||||
reader.onload = () => {
|
|
||||||
if (reader.result) {
|
|
||||||
resolve(reader.result as string);
|
|
||||||
} else {
|
|
||||||
reject(new Error("Failed to read text file content"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reader.readAsText(uploadFile);
|
|
||||||
} else {
|
|
||||||
reader.onload = () => {
|
|
||||||
if (reader.result instanceof ArrayBuffer) {
|
|
||||||
const bytes = new Uint8Array(reader.result);
|
|
||||||
let binary = "";
|
|
||||||
for (let i = 0; i < bytes.byteLength; i++) {
|
|
||||||
binary += String.fromCharCode(bytes[i]);
|
|
||||||
}
|
|
||||||
const base64 = btoa(binary);
|
|
||||||
resolve(base64);
|
|
||||||
} else {
|
|
||||||
reject(new Error("Failed to read binary file"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reader.readAsArrayBuffer(uploadFile);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const { uploadSSHFile } = await import("@/ui/main-axios.ts");
|
|
||||||
|
|
||||||
const response = await uploadSSHFile(
|
|
||||||
sshSessionId,
|
|
||||||
currentPath,
|
|
||||||
uploadFile.name,
|
|
||||||
content,
|
|
||||||
);
|
|
||||||
|
|
||||||
toast.dismiss(loadingToast);
|
|
||||||
|
|
||||||
if (response?.toast) {
|
|
||||||
toast[response.toast.type](response.toast.message);
|
|
||||||
} else {
|
|
||||||
onSuccess(
|
|
||||||
t("fileManager.fileUploadedSuccessfully", { name: uploadFile.name }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
setShowUpload(false);
|
|
||||||
setUploadFile(null);
|
|
||||||
onOperationComplete();
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.dismiss(loadingToast);
|
|
||||||
onError(
|
|
||||||
error?.response?.data?.error || t("fileManager.failedToUploadFile"),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateFile = async () => {
|
|
||||||
if (!newFileName.trim() || !sshSessionId) return;
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
const { toast } = await import("sonner");
|
|
||||||
const loadingToast = toast.loading(
|
|
||||||
t("fileManager.creatingFile", { name: newFileName.trim() }),
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { createSSHFile } = await import("@/ui/main-axios.ts");
|
|
||||||
|
|
||||||
const response = await createSSHFile(
|
|
||||||
sshSessionId,
|
|
||||||
currentPath,
|
|
||||||
newFileName.trim(),
|
|
||||||
);
|
|
||||||
|
|
||||||
toast.dismiss(loadingToast);
|
|
||||||
|
|
||||||
if (response?.toast) {
|
|
||||||
toast[response.toast.type](response.toast.message);
|
|
||||||
} else {
|
|
||||||
onSuccess(
|
|
||||||
t("fileManager.fileCreatedSuccessfully", {
|
|
||||||
name: newFileName.trim(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
setShowCreateFile(false);
|
|
||||||
setNewFileName("");
|
|
||||||
onOperationComplete();
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.dismiss(loadingToast);
|
|
||||||
onError(
|
|
||||||
error?.response?.data?.error || t("fileManager.failedToCreateFile"),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownload = async () => {
|
|
||||||
if (!downloadPath.trim() || !sshSessionId) return;
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
const { toast } = await import("sonner");
|
|
||||||
const fileName = downloadPath.split("/").pop() || "download";
|
|
||||||
const loadingToast = toast.loading(
|
|
||||||
t("fileManager.downloadingFile", { name: fileName }),
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { downloadSSHFile } = await import("@/ui/main-axios.ts");
|
|
||||||
|
|
||||||
const response = await downloadSSHFile(sshSessionId, downloadPath.trim());
|
|
||||||
|
|
||||||
toast.dismiss(loadingToast);
|
|
||||||
|
|
||||||
if (response?.content) {
|
|
||||||
// Convert base64 to blob and trigger download
|
|
||||||
const byteCharacters = atob(response.content);
|
|
||||||
const byteNumbers = new Array(byteCharacters.length);
|
|
||||||
for (let i = 0; i < byteCharacters.length; i++) {
|
|
||||||
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
|
||||||
}
|
|
||||||
const byteArray = new Uint8Array(byteNumbers);
|
|
||||||
const blob = new Blob([byteArray], {
|
|
||||||
type: response.mimeType || "application/octet-stream",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create download link
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = url;
|
|
||||||
link.download = response.fileName || fileName;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
|
|
||||||
onSuccess(
|
|
||||||
t("fileManager.fileDownloadedSuccessfully", {
|
|
||||||
name: response.fileName || fileName,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
onError(t("fileManager.noFileContent"));
|
|
||||||
}
|
|
||||||
|
|
||||||
setShowDownload(false);
|
|
||||||
setDownloadPath("");
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.dismiss(loadingToast);
|
|
||||||
onError(
|
|
||||||
error?.response?.data?.error || t("fileManager.failedToDownloadFile"),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateFolder = async () => {
|
|
||||||
if (!newFolderName.trim() || !sshSessionId) return;
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
const { toast } = await import("sonner");
|
|
||||||
const loadingToast = toast.loading(
|
|
||||||
t("fileManager.creatingFolder", { name: newFolderName.trim() }),
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { createSSHFolder } = await import("@/ui/main-axios.ts");
|
|
||||||
|
|
||||||
const response = await createSSHFolder(
|
|
||||||
sshSessionId,
|
|
||||||
currentPath,
|
|
||||||
newFolderName.trim(),
|
|
||||||
);
|
|
||||||
|
|
||||||
toast.dismiss(loadingToast);
|
|
||||||
|
|
||||||
if (response?.toast) {
|
|
||||||
toast[response.toast.type](response.toast.message);
|
|
||||||
} else {
|
|
||||||
onSuccess(
|
|
||||||
t("fileManager.folderCreatedSuccessfully", {
|
|
||||||
name: newFolderName.trim(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
setShowCreateFolder(false);
|
|
||||||
setNewFolderName("");
|
|
||||||
onOperationComplete();
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.dismiss(loadingToast);
|
|
||||||
onError(
|
|
||||||
error?.response?.data?.error || t("fileManager.failedToCreateFolder"),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
if (!deletePath || !sshSessionId) return;
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
const { toast } = await import("sonner");
|
|
||||||
const loadingToast = toast.loading(
|
|
||||||
t("fileManager.deletingItem", {
|
|
||||||
type: deleteIsDirectory
|
|
||||||
? t("fileManager.folder")
|
|
||||||
: t("fileManager.file"),
|
|
||||||
name: deletePath.split("/").pop(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { deleteSSHItem } = await import("@/ui/main-axios.ts");
|
|
||||||
|
|
||||||
const response = await deleteSSHItem(
|
|
||||||
sshSessionId,
|
|
||||||
deletePath,
|
|
||||||
deleteIsDirectory,
|
|
||||||
);
|
|
||||||
|
|
||||||
toast.dismiss(loadingToast);
|
|
||||||
|
|
||||||
if (response?.toast) {
|
|
||||||
toast[response.toast.type](response.toast.message);
|
|
||||||
} else {
|
|
||||||
onSuccess(
|
|
||||||
t("fileManager.itemDeletedSuccessfully", {
|
|
||||||
type: deleteIsDirectory
|
|
||||||
? t("fileManager.folder")
|
|
||||||
: t("fileManager.file"),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
setShowDelete(false);
|
|
||||||
setDeletePath("");
|
|
||||||
setDeleteIsDirectory(false);
|
|
||||||
onOperationComplete();
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.dismiss(loadingToast);
|
|
||||||
onError(
|
|
||||||
error?.response?.data?.error || t("fileManager.failedToDeleteItem"),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRename = async () => {
|
|
||||||
if (!renamePath || !newName.trim() || !sshSessionId) return;
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
const { toast } = await import("sonner");
|
|
||||||
const loadingToast = toast.loading(
|
|
||||||
t("fileManager.renamingItem", {
|
|
||||||
type: renameIsDirectory
|
|
||||||
? t("fileManager.folder")
|
|
||||||
: t("fileManager.file"),
|
|
||||||
oldName: renamePath.split("/").pop(),
|
|
||||||
newName: newName.trim(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { renameSSHItem } = await import("@/ui/main-axios.ts");
|
|
||||||
|
|
||||||
const response = await renameSSHItem(
|
|
||||||
sshSessionId,
|
|
||||||
renamePath,
|
|
||||||
newName.trim(),
|
|
||||||
);
|
|
||||||
|
|
||||||
toast.dismiss(loadingToast);
|
|
||||||
|
|
||||||
if (response?.toast) {
|
|
||||||
toast[response.toast.type](response.toast.message);
|
|
||||||
} else {
|
|
||||||
onSuccess(
|
|
||||||
t("fileManager.itemRenamedSuccessfully", {
|
|
||||||
type: renameIsDirectory
|
|
||||||
? t("fileManager.folder")
|
|
||||||
: t("fileManager.file"),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
setShowRename(false);
|
|
||||||
setRenamePath("");
|
|
||||||
setRenameIsDirectory(false);
|
|
||||||
setNewName("");
|
|
||||||
onOperationComplete();
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.dismiss(loadingToast);
|
|
||||||
onError(
|
|
||||||
error?.response?.data?.error || t("fileManager.failedToRenameItem"),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openFileDialog = () => {
|
|
||||||
fileInputRef.current?.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = event.target.files?.[0];
|
|
||||||
if (file) {
|
|
||||||
setUploadFile(file);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetStates = () => {
|
|
||||||
setShowUpload(false);
|
|
||||||
setShowCreateFile(false);
|
|
||||||
setShowCreateFolder(false);
|
|
||||||
setShowDelete(false);
|
|
||||||
setShowRename(false);
|
|
||||||
setUploadFile(null);
|
|
||||||
setNewFileName("");
|
|
||||||
setNewFolderName("");
|
|
||||||
setDeletePath("");
|
|
||||||
setDeleteIsDirectory(false);
|
|
||||||
setRenamePath("");
|
|
||||||
setRenameIsDirectory(false);
|
|
||||||
setNewName("");
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!sshSessionId) {
|
|
||||||
return (
|
|
||||||
<div className="p-4 text-center">
|
|
||||||
<AlertCircle className="w-8 h-8 text-muted-foreground mx-auto mb-2" />
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("fileManager.connectToSsh")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={containerRef} className="p-4 space-y-4">
|
|
||||||
<div className="grid grid-cols-3 gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowUpload(true)}
|
|
||||||
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover"
|
|
||||||
title={t("fileManager.uploadFile")}
|
|
||||||
>
|
|
||||||
<Upload className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
|
|
||||||
{showTextLabels && (
|
|
||||||
<span className="truncate">{t("fileManager.uploadFile")}</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowDownload(true)}
|
|
||||||
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover"
|
|
||||||
title={t("fileManager.downloadFile")}
|
|
||||||
>
|
|
||||||
<Download className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
|
|
||||||
{showTextLabels && (
|
|
||||||
<span className="truncate">{t("fileManager.downloadFile")}</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowCreateFile(true)}
|
|
||||||
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover"
|
|
||||||
title={t("fileManager.newFile")}
|
|
||||||
>
|
|
||||||
<FilePlus className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
|
|
||||||
{showTextLabels && (
|
|
||||||
<span className="truncate">{t("fileManager.newFile")}</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowCreateFolder(true)}
|
|
||||||
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover"
|
|
||||||
title={t("fileManager.newFolder")}
|
|
||||||
>
|
|
||||||
<FolderPlus className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
|
|
||||||
{showTextLabels && (
|
|
||||||
<span className="truncate">{t("fileManager.newFolder")}</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowRename(true)}
|
|
||||||
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover"
|
|
||||||
title={t("fileManager.rename")}
|
|
||||||
>
|
|
||||||
<Edit3 className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
|
|
||||||
{showTextLabels && (
|
|
||||||
<span className="truncate">{t("fileManager.rename")}</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowDelete(true)}
|
|
||||||
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover col-span-3"
|
|
||||||
title={t("fileManager.deleteItem")}
|
|
||||||
>
|
|
||||||
<Trash2 className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
|
|
||||||
{showTextLabels && (
|
|
||||||
<span className="truncate">{t("fileManager.deleteItem")}</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-dark-bg-light border-2 border-dark-border-medium rounded-md p-3">
|
|
||||||
<div className="flex items-start gap-2 text-sm">
|
|
||||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5" />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<span className="text-muted-foreground block mb-1">
|
|
||||||
{t("fileManager.currentPath")}:
|
|
||||||
</span>
|
|
||||||
<span className="text-white font-mono text-xs break-all leading-relaxed">
|
|
||||||
{currentPath}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator className="p-0.25 bg-dark-border" />
|
|
||||||
|
|
||||||
{showUpload && (
|
|
||||||
<Card className="bg-dark-bg border-2 border-dark-border p-3 sm:p-4">
|
|
||||||
<div className="flex items-start justify-between mb-3">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2 mb-1">
|
|
||||||
<Upload className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0" />
|
|
||||||
<span className="break-words">
|
|
||||||
{t("fileManager.uploadFileTitle")}
|
|
||||||
</span>
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-muted-foreground break-words">
|
|
||||||
{t("fileManager.maxFileSize")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowUpload(false)}
|
|
||||||
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="border-2 border-dashed border-dark-border-hover rounded-lg p-4 text-center">
|
|
||||||
{uploadFile ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<FileText className="w-12 h-12 text-blue-400 mx-auto" />
|
|
||||||
<p className="text-white font-medium text-sm break-words px-2">
|
|
||||||
{uploadFile.name}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{(uploadFile.size / 1024).toFixed(2)} KB
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setUploadFile(null)}
|
|
||||||
className="w-full text-sm h-8"
|
|
||||||
>
|
|
||||||
{t("fileManager.removeFile")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Upload className="w-12 h-12 text-muted-foreground mx-auto" />
|
|
||||||
<p className="text-white text-sm break-words px-2">
|
|
||||||
{t("fileManager.clickToSelectFile")}
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={openFileDialog}
|
|
||||||
className="w-full text-sm h-8"
|
|
||||||
>
|
|
||||||
{t("fileManager.chooseFile")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
onChange={handleFileSelect}
|
|
||||||
className="hidden"
|
|
||||||
accept="*/*"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={handleFileUpload}
|
|
||||||
disabled={!uploadFile || isLoading}
|
|
||||||
className="w-full text-sm h-9"
|
|
||||||
>
|
|
||||||
{isLoading
|
|
||||||
? t("fileManager.uploading")
|
|
||||||
: t("fileManager.uploadFile")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowUpload(false)}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="w-full text-sm h-9"
|
|
||||||
>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showDownload && (
|
|
||||||
<Card className="bg-dark-bg border-2 border-dark-border p-3 sm:p-4">
|
|
||||||
<div className="flex items-start justify-between mb-3">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
|
|
||||||
<Download className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0" />
|
|
||||||
<span className="break-words">
|
|
||||||
{t("fileManager.downloadFile")}
|
|
||||||
</span>
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowDownload(false)}
|
|
||||||
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-white mb-2 block">
|
|
||||||
{t("fileManager.filePath")}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={downloadPath}
|
|
||||||
onChange={(e) => setDownloadPath(e.target.value)}
|
|
||||||
placeholder={t("placeholders.fullPath")}
|
|
||||||
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleDownload()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={handleDownload}
|
|
||||||
disabled={!downloadPath.trim() || isLoading}
|
|
||||||
className="w-full text-sm h-9"
|
|
||||||
>
|
|
||||||
{isLoading
|
|
||||||
? t("fileManager.downloading")
|
|
||||||
: t("fileManager.downloadFile")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowDownload(false)}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="w-full text-sm h-9"
|
|
||||||
>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showCreateFile && (
|
|
||||||
<Card className="bg-dark-bg border-2 border-dark-border p-3 sm:p-4">
|
|
||||||
<div className="flex items-start justify-between mb-3">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
|
|
||||||
<FilePlus className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0" />
|
|
||||||
<span className="break-words">
|
|
||||||
{t("fileManager.createNewFile")}
|
|
||||||
</span>
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowCreateFile(false)}
|
|
||||||
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-white mb-2 block">
|
|
||||||
{t("fileManager.fileName")}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={newFileName}
|
|
||||||
onChange={(e) => setNewFileName(e.target.value)}
|
|
||||||
placeholder={t("placeholders.fileName")}
|
|
||||||
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleCreateFile()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={handleCreateFile}
|
|
||||||
disabled={!newFileName.trim() || isLoading}
|
|
||||||
className="w-full text-sm h-9"
|
|
||||||
>
|
|
||||||
{isLoading
|
|
||||||
? t("fileManager.creating")
|
|
||||||
: t("fileManager.createFile")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowCreateFile(false)}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="w-full text-sm h-9"
|
|
||||||
>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showCreateFolder && (
|
|
||||||
<Card className="bg-dark-bg border-2 border-dark-border p-3">
|
|
||||||
<div className="flex items-start justify-between mb-3">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="text-base font-semibold text-white flex items-center gap-2">
|
|
||||||
<FolderPlus className="w-6 h-6 flex-shrink-0" />
|
|
||||||
<span className="break-words">
|
|
||||||
{t("fileManager.createNewFolder")}
|
|
||||||
</span>
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowCreateFolder(false)}
|
|
||||||
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-white mb-2 block">
|
|
||||||
{t("fileManager.folderName")}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={newFolderName}
|
|
||||||
onChange={(e) => setNewFolderName(e.target.value)}
|
|
||||||
placeholder={t("placeholders.folderName")}
|
|
||||||
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleCreateFolder()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={handleCreateFolder}
|
|
||||||
disabled={!newFolderName.trim() || isLoading}
|
|
||||||
className="w-full text-sm h-9"
|
|
||||||
>
|
|
||||||
{isLoading
|
|
||||||
? t("fileManager.creating")
|
|
||||||
: t("fileManager.createFolder")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowCreateFolder(false)}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="w-full text-sm h-9"
|
|
||||||
>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showDelete && (
|
|
||||||
<Card className="bg-dark-bg border-2 border-dark-border p-3">
|
|
||||||
<div className="flex items-start justify-between mb-3">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="text-base font-semibold text-white flex items-center gap-2">
|
|
||||||
<Trash2 className="w-6 h-6 text-red-400 flex-shrink-0" />
|
|
||||||
<span className="break-words">
|
|
||||||
{t("fileManager.deleteItem")}
|
|
||||||
</span>
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowDelete(false)}
|
|
||||||
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="bg-red-900/20 border border-red-500/30 rounded-lg p-3">
|
|
||||||
<div className="flex items-start gap-2 text-red-300">
|
|
||||||
<AlertCircle className="w-5 h-5 flex-shrink-0" />
|
|
||||||
<span className="text-sm font-medium break-words">
|
|
||||||
{t("fileManager.warningCannotUndo")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-white mb-2 block">
|
|
||||||
{t("fileManager.itemPath")}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={deletePath}
|
|
||||||
onChange={(e) => setDeletePath(e.target.value)}
|
|
||||||
placeholder={t("placeholders.fullPath")}
|
|
||||||
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="deleteIsDirectory"
|
|
||||||
checked={deleteIsDirectory}
|
|
||||||
onChange={(e) => setDeleteIsDirectory(e.target.checked)}
|
|
||||||
className="rounded border-dark-border-hover bg-dark-bg-button mt-0.5 flex-shrink-0"
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor="deleteIsDirectory"
|
|
||||||
className="text-sm text-white break-words"
|
|
||||||
>
|
|
||||||
{t("fileManager.thisIsDirectory")}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={handleDelete}
|
|
||||||
disabled={!deletePath || isLoading}
|
|
||||||
variant="destructive"
|
|
||||||
className="w-full text-sm h-9"
|
|
||||||
>
|
|
||||||
{isLoading
|
|
||||||
? t("fileManager.deleting")
|
|
||||||
: t("fileManager.deleteItem")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowDelete(false)}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="w-full text-sm h-9"
|
|
||||||
>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showRename && (
|
|
||||||
<Card className="bg-dark-bg border-2 border-dark-border p-3">
|
|
||||||
<div className="flex items-start justify-between mb-3">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="text-base font-semibold text-white flex items-center gap-2">
|
|
||||||
<Edit3 className="w-6 h-6 flex-shrink-0" />
|
|
||||||
<span className="break-words">
|
|
||||||
{t("fileManager.renameItem")}
|
|
||||||
</span>
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowRename(false)}
|
|
||||||
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-white mb-2 block">
|
|
||||||
{t("fileManager.currentPathLabel")}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={renamePath}
|
|
||||||
onChange={(e) => setRenamePath(e.target.value)}
|
|
||||||
placeholder={t("placeholders.currentPath")}
|
|
||||||
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-white mb-2 block">
|
|
||||||
{t("fileManager.newName")}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={newName}
|
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
|
||||||
placeholder={t("placeholders.newName")}
|
|
||||||
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleRename()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="renameIsDirectory"
|
|
||||||
checked={renameIsDirectory}
|
|
||||||
onChange={(e) => setRenameIsDirectory(e.target.checked)}
|
|
||||||
className="rounded border-dark-border-hover bg-dark-bg-button mt-0.5 flex-shrink-0"
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor="renameIsDirectory"
|
|
||||||
className="text-sm text-white break-words"
|
|
||||||
>
|
|
||||||
{t("fileManager.thisIsDirectoryRename")}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={handleRename}
|
|
||||||
disabled={!renamePath || !newName.trim() || isLoading}
|
|
||||||
className="w-full text-sm h-9"
|
|
||||||
>
|
|
||||||
{isLoading
|
|
||||||
? t("fileManager.renaming")
|
|
||||||
: t("fileManager.renameItem")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowRename(false)}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="w-full text-sm h-9"
|
|
||||||
>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Button } from "@/components/ui/button.tsx";
|
|
||||||
import { X, Home } from "lucide-react";
|
|
||||||
|
|
||||||
interface FileManagerTab {
|
|
||||||
id: string | number;
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FileManagerTabList {
|
|
||||||
tabs: FileManagerTab[];
|
|
||||||
activeTab: string | number;
|
|
||||||
setActiveTab: (tab: string | number) => void;
|
|
||||||
closeTab: (tab: string | number) => void;
|
|
||||||
onHomeClick: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FileManagerTabList({
|
|
||||||
tabs,
|
|
||||||
activeTab,
|
|
||||||
setActiveTab,
|
|
||||||
closeTab,
|
|
||||||
onHomeClick,
|
|
||||||
}: FileManagerTabList) {
|
|
||||||
return (
|
|
||||||
<div className="inline-flex items-center h-full gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={onHomeClick}
|
|
||||||
variant="outline"
|
|
||||||
className={`ml-1 h-8 rounded-md flex items-center !px-2 border-1 border-dark-border ${activeTab === "home" ? "!bg-dark-bg-active !text-white !border-dark-border-active !hover:bg-dark-bg-active !active:bg-dark-bg-active !focus:bg-dark-bg-active !hover:text-white !active:text-white !focus:text-white" : ""}`}
|
|
||||||
>
|
|
||||||
<Home className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
{tabs.map((tab) => {
|
|
||||||
const isActive = tab.id === activeTab;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={tab.id}
|
|
||||||
className="inline-flex rounded-md shadow-sm"
|
|
||||||
role="group"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
onClick={() => setActiveTab(tab.id)}
|
|
||||||
variant="outline"
|
|
||||||
className={`h-8 rounded-r-none !px-2 border-1 border-dark-border ${isActive ? "!bg-dark-bg-active !text-white !border-dark-border-active !hover:bg-dark-bg-active !active:bg-dark-bg-active !focus:bg-dark-bg-active !hover:text-white !active:text-white !focus:text-white" : ""}`}
|
|
||||||
>
|
|
||||||
{tab.title}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={() => closeTab(tab.id)}
|
|
||||||
variant="outline"
|
|
||||||
className="h-8 rounded-l-none p-0 !w-9 border-1 border-dark-border"
|
|
||||||
>
|
|
||||||
<X className="!w-4 !h-4" strokeWidth={2} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
Save,
|
Save,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
|
Search,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
SiJavascript,
|
SiJavascript,
|
||||||
@@ -49,7 +50,7 @@ import CodeMirror from "@uiw/react-codemirror";
|
|||||||
import { oneDark } from "@codemirror/theme-one-dark";
|
import { oneDark } from "@codemirror/theme-one-dark";
|
||||||
import { loadLanguage } from "@uiw/codemirror-extensions-langs";
|
import { loadLanguage } from "@uiw/codemirror-extensions-langs";
|
||||||
import { EditorView, keymap } from "@codemirror/view";
|
import { EditorView, keymap } from "@codemirror/view";
|
||||||
import { searchKeymap, search } from "@codemirror/search";
|
import { searchKeymap, search, openSearchPanel } from "@codemirror/search";
|
||||||
import { defaultKeymap, history, historyKeymap, toggleComment } from "@codemirror/commands";
|
import { defaultKeymap, history, historyKeymap, toggleComment } from "@codemirror/commands";
|
||||||
import { autocompletion, completionKeymap } from "@codemirror/autocomplete";
|
import { autocompletion, completionKeymap } from "@codemirror/autocomplete";
|
||||||
import { PhotoProvider, PhotoView } from "react-photo-view";
|
import { PhotoProvider, PhotoView } from "react-photo-view";
|
||||||
@@ -323,6 +324,7 @@ export function FileViewer({
|
|||||||
const [pdfScale, setPdfScale] = useState(1.2);
|
const [pdfScale, setPdfScale] = useState(1.2);
|
||||||
const [pdfError, setPdfError] = useState(false);
|
const [pdfError, setPdfError] = useState(false);
|
||||||
const [markdownEditMode, setMarkdownEditMode] = useState(false);
|
const [markdownEditMode, setMarkdownEditMode] = useState(false);
|
||||||
|
const editorRef = useRef<any>(null);
|
||||||
|
|
||||||
const fileTypeInfo = getFileType(file.name);
|
const fileTypeInfo = getFileType(file.name);
|
||||||
|
|
||||||
@@ -348,7 +350,8 @@ export function FileViewer({
|
|||||||
if (savedContent) {
|
if (savedContent) {
|
||||||
setOriginalContent(savedContent);
|
setOriginalContent(savedContent);
|
||||||
}
|
}
|
||||||
setHasChanges(content !== (savedContent || content));
|
// Fix: Compare current content with saved content properly
|
||||||
|
setHasChanges(content !== savedContent);
|
||||||
|
|
||||||
// If unknown file type and file is large, show warning
|
// If unknown file type and file is large, show warning
|
||||||
if (fileTypeInfo.type === "unknown" && isLargeFile && !forceShowAsText) {
|
if (fileTypeInfo.type === "unknown" && isLargeFile && !forceShowAsText) {
|
||||||
@@ -361,7 +364,8 @@ export function FileViewer({
|
|||||||
// Handle content changes
|
// Handle content changes
|
||||||
const handleContentChange = (newContent: string) => {
|
const handleContentChange = (newContent: string) => {
|
||||||
setEditedContent(newContent);
|
setEditedContent(newContent);
|
||||||
setHasChanges(newContent !== originalContent);
|
// Fix: Compare with savedContent instead of originalContent for consistency
|
||||||
|
setHasChanges(newContent !== savedContent);
|
||||||
onContentChange?.(newContent);
|
onContentChange?.(newContent);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -373,9 +377,9 @@ export function FileViewer({
|
|||||||
|
|
||||||
// Revert file
|
// Revert file
|
||||||
const handleRevert = () => {
|
const handleRevert = () => {
|
||||||
setEditedContent(originalContent);
|
setEditedContent(savedContent);
|
||||||
setHasChanges(false);
|
setHasChanges(false);
|
||||||
onContentChange?.(originalContent);
|
onContentChange?.(savedContent);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle save shortcut specifically
|
// Handle save shortcut specifically
|
||||||
@@ -453,6 +457,26 @@ export function FileViewer({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Search button */}
|
||||||
|
{isEditable && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
// Use CodeMirror's proper API to open search panel
|
||||||
|
if (editorRef.current) {
|
||||||
|
const view = editorRef.current.view;
|
||||||
|
if (view) {
|
||||||
|
openSearchPanel(view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
title="Search in file (Ctrl+F)"
|
||||||
|
>
|
||||||
|
<Search className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{/* Keyboard shortcuts help */}
|
{/* Keyboard shortcuts help */}
|
||||||
{isEditable && (
|
{isEditable && (
|
||||||
<Button
|
<Button
|
||||||
@@ -557,14 +581,13 @@ export function FileViewer({
|
|||||||
<span>{t("fileManager.redo")}</span>
|
<span>{t("fileManager.redo")}</span>
|
||||||
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+Y</kbd>
|
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+Y</kbd>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h4 className="font-medium text-muted-foreground">{t("fileManager.navigation")}</h4>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>{t("fileManager.goToLine")}</span>
|
<span>{t("fileManager.toggleComment")}</span>
|
||||||
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+G</kbd>
|
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+/</kbd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>{t("fileManager.autoComplete")}</span>
|
||||||
|
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+Space</kbd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>{t("fileManager.moveLineUp")}</span>
|
<span>{t("fileManager.moveLineUp")}</span>
|
||||||
@@ -576,27 +599,6 @@ export function FileViewer({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<h4 className="font-medium text-muted-foreground">{t("fileManager.code")}</h4>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>{t("fileManager.toggleComment")}</span>
|
|
||||||
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+/</kbd>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>{t("fileManager.indent")}</span>
|
|
||||||
<kbd className="px-2 py-1 bg-background rounded text-xs">Tab</kbd>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>{t("fileManager.outdent")}</span>
|
|
||||||
<kbd className="px-2 py-1 bg-background rounded text-xs">Shift+Tab</kbd>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>{t("fileManager.autoComplete")}</span>
|
|
||||||
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+Space</kbd>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -737,6 +739,7 @@ export function FileViewer({
|
|||||||
{isEditable ? (
|
{isEditable ? (
|
||||||
// Unified CodeMirror editor for all text-based files
|
// Unified CodeMirror editor for all text-based files
|
||||||
<CodeMirror
|
<CodeMirror
|
||||||
|
ref={editorRef}
|
||||||
value={editedContent}
|
value={editedContent}
|
||||||
onChange={(value) => handleContentChange(value)}
|
onChange={(value) => handleContentChange(value)}
|
||||||
onFocus={() => setEditorFocused(true)}
|
onFocus={() => setEditorFocused(true)}
|
||||||
@@ -906,17 +909,7 @@ export function FileViewer({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{hasChanges && (
|
{/* Save button removed - using the main header save button instead */}
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onSave?.(editedContent)}
|
|
||||||
disabled={!hasChanges}
|
|
||||||
>
|
|
||||||
<Save className="w-4 h-4 mr-1" />
|
|
||||||
{t("fileManager.save")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -525,13 +525,13 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
|||||||
const sampleData = {
|
const sampleData = {
|
||||||
hosts: [
|
hosts: [
|
||||||
{
|
{
|
||||||
name: "Web Server - Production",
|
name: t("interface.webServerProduction"),
|
||||||
ip: "192.168.1.100",
|
ip: "192.168.1.100",
|
||||||
port: 22,
|
port: 22,
|
||||||
username: "admin",
|
username: "admin",
|
||||||
authType: "password",
|
authType: "password",
|
||||||
password: "your_secure_password_here",
|
password: "your_secure_password_here",
|
||||||
folder: "Production",
|
folder: t("interface.productionFolder"),
|
||||||
tags: ["web", "production", "nginx"],
|
tags: ["web", "production", "nginx"],
|
||||||
pin: true,
|
pin: true,
|
||||||
enableTerminal: true,
|
enableTerminal: true,
|
||||||
@@ -540,7 +540,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
|||||||
defaultPath: "/var/www",
|
defaultPath: "/var/www",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Database Server",
|
name: t("interface.databaseServer"),
|
||||||
ip: "192.168.1.101",
|
ip: "192.168.1.101",
|
||||||
port: 22,
|
port: 22,
|
||||||
username: "dbadmin",
|
username: "dbadmin",
|
||||||
@@ -548,7 +548,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
|||||||
key: "-----BEGIN OPENSSH PRIVATE KEY-----\nYour SSH private key content here\n-----END OPENSSH PRIVATE KEY-----",
|
key: "-----BEGIN OPENSSH PRIVATE KEY-----\nYour SSH private key content here\n-----END OPENSSH PRIVATE KEY-----",
|
||||||
keyPassword: "optional_key_passphrase",
|
keyPassword: "optional_key_passphrase",
|
||||||
keyType: "ssh-ed25519",
|
keyType: "ssh-ed25519",
|
||||||
folder: "Production",
|
folder: t("interface.productionFolder"),
|
||||||
tags: ["database", "production", "postgresql"],
|
tags: ["database", "production", "postgresql"],
|
||||||
pin: false,
|
pin: false,
|
||||||
enableTerminal: true,
|
enableTerminal: true,
|
||||||
@@ -558,7 +558,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
|||||||
{
|
{
|
||||||
sourcePort: 5432,
|
sourcePort: 5432,
|
||||||
endpointPort: 5432,
|
endpointPort: 5432,
|
||||||
endpointHost: "Web Server - Production",
|
endpointHost: t("interface.webServerProduction"),
|
||||||
maxRetries: 3,
|
maxRetries: 3,
|
||||||
retryInterval: 10,
|
retryInterval: 10,
|
||||||
autoStart: true,
|
autoStart: true,
|
||||||
@@ -566,13 +566,13 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Development Server",
|
name: t("interface.developmentServer"),
|
||||||
ip: "192.168.1.102",
|
ip: "192.168.1.102",
|
||||||
port: 2222,
|
port: 2222,
|
||||||
username: "developer",
|
username: "developer",
|
||||||
authType: "credential",
|
authType: "credential",
|
||||||
credentialId: 1,
|
credentialId: 1,
|
||||||
folder: "Development",
|
folder: t("interface.developmentFolder"),
|
||||||
tags: ["dev", "testing"],
|
tags: ["dev", "testing"],
|
||||||
pin: false,
|
pin: false,
|
||||||
enableTerminal: true,
|
enableTerminal: true,
|
||||||
@@ -686,13 +686,13 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
|||||||
const sampleData = {
|
const sampleData = {
|
||||||
hosts: [
|
hosts: [
|
||||||
{
|
{
|
||||||
name: "Web Server - Production",
|
name: t("interface.webServerProduction"),
|
||||||
ip: "192.168.1.100",
|
ip: "192.168.1.100",
|
||||||
port: 22,
|
port: 22,
|
||||||
username: "admin",
|
username: "admin",
|
||||||
authType: "password",
|
authType: "password",
|
||||||
password: "your_secure_password_here",
|
password: "your_secure_password_here",
|
||||||
folder: "Production",
|
folder: t("interface.productionFolder"),
|
||||||
tags: ["web", "production", "nginx"],
|
tags: ["web", "production", "nginx"],
|
||||||
pin: true,
|
pin: true,
|
||||||
enableTerminal: true,
|
enableTerminal: true,
|
||||||
@@ -701,7 +701,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
|||||||
defaultPath: "/var/www",
|
defaultPath: "/var/www",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Database Server",
|
name: t("interface.databaseServer"),
|
||||||
ip: "192.168.1.101",
|
ip: "192.168.1.101",
|
||||||
port: 22,
|
port: 22,
|
||||||
username: "dbadmin",
|
username: "dbadmin",
|
||||||
@@ -709,7 +709,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
|||||||
key: "-----BEGIN OPENSSH PRIVATE KEY-----\nYour SSH private key content here\n-----END OPENSSH PRIVATE KEY-----",
|
key: "-----BEGIN OPENSSH PRIVATE KEY-----\nYour SSH private key content here\n-----END OPENSSH PRIVATE KEY-----",
|
||||||
keyPassword: "optional_key_passphrase",
|
keyPassword: "optional_key_passphrase",
|
||||||
keyType: "ssh-ed25519",
|
keyType: "ssh-ed25519",
|
||||||
folder: "Production",
|
folder: t("interface.productionFolder"),
|
||||||
tags: ["database", "production", "postgresql"],
|
tags: ["database", "production", "postgresql"],
|
||||||
pin: false,
|
pin: false,
|
||||||
enableTerminal: true,
|
enableTerminal: true,
|
||||||
@@ -719,7 +719,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
|||||||
{
|
{
|
||||||
sourcePort: 5432,
|
sourcePort: 5432,
|
||||||
endpointPort: 5432,
|
endpointPort: 5432,
|
||||||
endpointHost: "Web Server - Production",
|
endpointHost: t("interface.webServerProduction"),
|
||||||
maxRetries: 3,
|
maxRetries: 3,
|
||||||
retryInterval: 10,
|
retryInterval: 10,
|
||||||
autoStart: true,
|
autoStart: true,
|
||||||
@@ -727,13 +727,13 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Development Server",
|
name: t("interface.developmentServer"),
|
||||||
ip: "192.168.1.102",
|
ip: "192.168.1.102",
|
||||||
port: 2222,
|
port: 2222,
|
||||||
username: "developer",
|
username: "developer",
|
||||||
authType: "credential",
|
authType: "credential",
|
||||||
credentialId: 1,
|
credentialId: 1,
|
||||||
folder: "Development",
|
folder: t("interface.developmentFolder"),
|
||||||
tags: ["dev", "testing"],
|
tags: ["dev", "testing"],
|
||||||
pin: false,
|
pin: false,
|
||||||
enableTerminal: true,
|
enableTerminal: true,
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export function Server({
|
|||||||
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
||||||
const [isLoadingMetrics, setIsLoadingMetrics] = React.useState(false);
|
const [isLoadingMetrics, setIsLoadingMetrics] = React.useState(false);
|
||||||
const [isRefreshing, setIsRefreshing] = React.useState(false);
|
const [isRefreshing, setIsRefreshing] = React.useState(false);
|
||||||
|
const [showStatsUI, setShowStatsUI] = React.useState(true);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setCurrentHostConfig(hostConfig);
|
setCurrentHostConfig(hostConfig);
|
||||||
@@ -116,10 +117,12 @@ export function Server({
|
|||||||
const data = await getServerMetricsById(currentHostConfig.id);
|
const data = await getServerMetricsById(currentHostConfig.id);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setMetrics(data);
|
setMetrics(data);
|
||||||
|
setShowStatsUI(true);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setMetrics(null);
|
setMetrics(null);
|
||||||
|
setShowStatsUI(false);
|
||||||
toast.error(t("serverStats.failedToFetchMetrics"));
|
toast.error(t("serverStats.failedToFetchMetrics"));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -208,6 +211,7 @@ export function Server({
|
|||||||
currentHostConfig.id,
|
currentHostConfig.id,
|
||||||
);
|
);
|
||||||
setMetrics(data);
|
setMetrics(data);
|
||||||
|
setShowStatsUI(true);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error?.response?.status === 503) {
|
if (error?.response?.status === 503) {
|
||||||
setServerStatus("offline");
|
setServerStatus("offline");
|
||||||
@@ -219,6 +223,7 @@ export function Server({
|
|||||||
setServerStatus("offline");
|
setServerStatus("offline");
|
||||||
}
|
}
|
||||||
setMetrics(null);
|
setMetrics(null);
|
||||||
|
setShowStatsUI(false);
|
||||||
} finally {
|
} finally {
|
||||||
setIsRefreshing(false);
|
setIsRefreshing(false);
|
||||||
}
|
}
|
||||||
@@ -267,7 +272,8 @@ export function Server({
|
|||||||
<Separator className="p-0.25 w-full" />
|
<Separator className="p-0.25 w-full" />
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4">
|
{showStatsUI && (
|
||||||
|
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4">
|
||||||
{isLoadingMetrics && !metrics ? (
|
{isLoadingMetrics && !metrics ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -443,7 +449,8 @@ export function Server({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* SSH Tunnels */}
|
{/* SSH Tunnels */}
|
||||||
{currentHostConfig?.tunnelConnections &&
|
{currentHostConfig?.tunnelConnections &&
|
||||||
|
|||||||
@@ -304,18 +304,18 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const baseWsUrl = isDev
|
const baseWsUrl = isDev
|
||||||
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:8082`
|
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`
|
||||||
: isElectron()
|
: isElectron()
|
||||||
? (() => {
|
? (() => {
|
||||||
const baseUrl =
|
const baseUrl =
|
||||||
(window as any).configuredServerUrl || "http://127.0.0.1:8081";
|
(window as any).configuredServerUrl || "http://127.0.0.1:30001";
|
||||||
const wsProtocol = baseUrl.startsWith("https://")
|
const wsProtocol = baseUrl.startsWith("https://")
|
||||||
? "wss://"
|
? "wss://"
|
||||||
: "ws://";
|
: "ws://";
|
||||||
const wsHost = baseUrl.replace(/^https?:\/\//, "");
|
const wsHost = baseUrl.replace(/^https?:\/\//, "");
|
||||||
return `${wsProtocol}${wsHost.replace(':8081', ':8082')}/`;
|
return `${wsProtocol}${wsHost.replace(':30001', ':30002')}/`;
|
||||||
})()
|
})()
|
||||||
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.hostname}:8082/`;
|
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.hostname}:30002/`;
|
||||||
|
|
||||||
// Clean up existing connection to prevent duplicates - Linus principle: eliminate complexity
|
// Clean up existing connection to prevent duplicates - Linus principle: eliminate complexity
|
||||||
if (webSocketRef.current && webSocketRef.current.readyState !== WebSocket.CLOSED) {
|
if (webSocketRef.current && webSocketRef.current.readyState !== WebSocket.CLOSED) {
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export function ServerConfig({
|
|||||||
<Input
|
<Input
|
||||||
id="server-url"
|
id="server-url"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="http://localhost:8081 or https://your-server.com"
|
placeholder="http://localhost:30001 or https://your-server.com"
|
||||||
value={serverUrl}
|
value={serverUrl}
|
||||||
onChange={(e) => handleUrlChange(e.target.value)}
|
onChange={(e) => handleUrlChange(e.target.value)}
|
||||||
className="flex-1 h-10"
|
className="flex-1 h-10"
|
||||||
|
|||||||
@@ -27,13 +27,11 @@ export function HomepageAlertManager({
|
|||||||
}, [loggedIn, userId]);
|
}, [loggedIn, userId]);
|
||||||
|
|
||||||
const fetchUserAlerts = async () => {
|
const fetchUserAlerts = async () => {
|
||||||
if (!userId) return;
|
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await getUserAlerts(userId);
|
const response = await getUserAlerts();
|
||||||
const userAlerts = response.alerts || [];
|
const userAlerts = response.alerts || [];
|
||||||
|
|
||||||
const sortedAlerts = userAlerts.sort((a: TermixAlert, b: TermixAlert) => {
|
const sortedAlerts = userAlerts.sort((a: TermixAlert, b: TermixAlert) => {
|
||||||
@@ -64,10 +62,8 @@ export function HomepageAlertManager({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDismissAlert = async (alertId: string) => {
|
const handleDismissAlert = async (alertId: string) => {
|
||||||
if (!userId) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await dismissAlert(userId, alertId);
|
await dismissAlert(alertId);
|
||||||
|
|
||||||
setAlerts((prev) => {
|
setAlerts((prev) => {
|
||||||
const newAlerts = prev.filter((alert) => alert.id !== alertId);
|
const newAlerts = prev.filter((alert) => alert.id !== alertId);
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { Button } from "@/components/ui/button.tsx";
|
|||||||
import { Input } from "@/components/ui/input.tsx";
|
import { Input } from "@/components/ui/input.tsx";
|
||||||
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
||||||
import { Label } from "@/components/ui/label.tsx";
|
import { Label } from "@/components/ui/label.tsx";
|
||||||
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert.tsx";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { LanguageSwitcher } from "@/ui/Desktop/User/LanguageSwitcher.tsx";
|
import { LanguageSwitcher } from "@/ui/Desktop/User/LanguageSwitcher.tsx";
|
||||||
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
registerUser,
|
registerUser,
|
||||||
loginUser,
|
loginUser,
|
||||||
@@ -124,20 +124,32 @@ export function HomepageAuth({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setDbHealthChecking(true);
|
||||||
getSetupRequired()
|
getSetupRequired()
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (res.setup_required) {
|
if (res.setup_required) {
|
||||||
setFirstUser(true);
|
setFirstUser(true);
|
||||||
setTab("signup");
|
setTab("signup");
|
||||||
|
toast.info(t("auth.firstUserMessage"));
|
||||||
} else {
|
} else {
|
||||||
setFirstUser(false);
|
setFirstUser(false);
|
||||||
}
|
}
|
||||||
setDbError(null);
|
setDbError(null);
|
||||||
|
setDbConnectionFailed(false);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setDbError(t("errors.databaseConnection"));
|
setDbConnectionFailed(true);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setDbHealthChecking(false);
|
||||||
});
|
});
|
||||||
}, [setDbError]);
|
}, [setDbError, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!registrationAllowed && !internalLoggedIn) {
|
||||||
|
toast.warning(t("messages.registrationDisabled"));
|
||||||
|
}
|
||||||
|
}, [registrationAllowed, internalLoggedIn, t]);
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -145,7 +157,7 @@ export function HomepageAuth({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
if (!localUsername.trim()) {
|
if (!localUsername.trim()) {
|
||||||
setError(t("errors.requiredField"));
|
toast.error(t("errors.requiredField"));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -156,12 +168,12 @@ export function HomepageAuth({
|
|||||||
res = await loginUser(localUsername, password);
|
res = await loginUser(localUsername, password);
|
||||||
} else {
|
} else {
|
||||||
if (password !== signupConfirmPassword) {
|
if (password !== signupConfirmPassword) {
|
||||||
setError(t("errors.passwordMismatch"));
|
toast.error(t("errors.passwordMismatch"));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (password.length < 6) {
|
if (password.length < 6) {
|
||||||
setError(t("errors.minLength", { min: 6 }));
|
toast.error(t("errors.minLength", { min: 6 }));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -199,22 +211,25 @@ export function HomepageAuth({
|
|||||||
setLoggedIn(true);
|
setLoggedIn(true);
|
||||||
setIsAdmin(!!meRes.is_admin);
|
setIsAdmin(!!meRes.is_admin);
|
||||||
setUsername(meRes.username || null);
|
setUsername(meRes.username || null);
|
||||||
setUserId(meRes.id || null);
|
setUserId(meRes.userId || null);
|
||||||
setDbError(null);
|
setDbError(null);
|
||||||
onAuthSuccess({
|
onAuthSuccess({
|
||||||
isAdmin: !!meRes.is_admin,
|
isAdmin: !!meRes.is_admin,
|
||||||
username: meRes.username || null,
|
username: meRes.username || null,
|
||||||
userId: meRes.id || null,
|
userId: meRes.userId || null,
|
||||||
});
|
});
|
||||||
setInternalLoggedIn(true);
|
setInternalLoggedIn(true);
|
||||||
if (tab === "signup") {
|
if (tab === "signup") {
|
||||||
setSignupConfirmPassword("");
|
setSignupConfirmPassword("");
|
||||||
|
toast.success(t("messages.registrationSuccess"));
|
||||||
|
} else {
|
||||||
|
toast.success(t("messages.loginSuccess"));
|
||||||
}
|
}
|
||||||
setTotpRequired(false);
|
setTotpRequired(false);
|
||||||
setTotpCode("");
|
setTotpCode("");
|
||||||
setTotpTempToken("");
|
setTotpTempToken("");
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(
|
toast.error(
|
||||||
err?.response?.data?.error || err?.message || t("errors.unknownError"),
|
err?.response?.data?.error || err?.message || t("errors.unknownError"),
|
||||||
);
|
);
|
||||||
setInternalLoggedIn(false);
|
setInternalLoggedIn(false);
|
||||||
@@ -224,7 +239,7 @@ export function HomepageAuth({
|
|||||||
setUserId(null);
|
setUserId(null);
|
||||||
setCookie("jwt", "", -1);
|
setCookie("jwt", "", -1);
|
||||||
if (err?.response?.data?.error?.includes("Database")) {
|
if (err?.response?.data?.error?.includes("Database")) {
|
||||||
setDbError(t("errors.databaseConnection"));
|
setDbConnectionFailed(true);
|
||||||
} else {
|
} else {
|
||||||
setDbError(null);
|
setDbError(null);
|
||||||
}
|
}
|
||||||
@@ -239,9 +254,9 @@ export function HomepageAuth({
|
|||||||
try {
|
try {
|
||||||
const result = await initiatePasswordReset(localUsername);
|
const result = await initiatePasswordReset(localUsername);
|
||||||
setResetStep("verify");
|
setResetStep("verify");
|
||||||
setError(null);
|
toast.success(t("messages.resetCodeSent"));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(
|
toast.error(
|
||||||
err?.response?.data?.error ||
|
err?.response?.data?.error ||
|
||||||
err?.message ||
|
err?.message ||
|
||||||
t("errors.failedPasswordReset"),
|
t("errors.failedPasswordReset"),
|
||||||
@@ -258,9 +273,9 @@ export function HomepageAuth({
|
|||||||
const response = await verifyPasswordResetCode(localUsername, resetCode);
|
const response = await verifyPasswordResetCode(localUsername, resetCode);
|
||||||
setTempToken(response.tempToken);
|
setTempToken(response.tempToken);
|
||||||
setResetStep("newPassword");
|
setResetStep("newPassword");
|
||||||
setError(null);
|
toast.success(t("messages.codeVerified"));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err?.response?.data?.error || t("errors.failedVerifyCode"));
|
toast.error(err?.response?.data?.error || t("errors.failedVerifyCode"));
|
||||||
} finally {
|
} finally {
|
||||||
setResetLoading(false);
|
setResetLoading(false);
|
||||||
}
|
}
|
||||||
@@ -271,13 +286,13 @@ export function HomepageAuth({
|
|||||||
setResetLoading(true);
|
setResetLoading(true);
|
||||||
|
|
||||||
if (newPassword !== confirmPassword) {
|
if (newPassword !== confirmPassword) {
|
||||||
setError(t("errors.passwordMismatch"));
|
toast.error(t("errors.passwordMismatch"));
|
||||||
setResetLoading(false);
|
setResetLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newPassword.length < 6) {
|
if (newPassword.length < 6) {
|
||||||
setError(t("errors.minLength", { min: 6 }));
|
toast.error(t("errors.minLength", { min: 6 }));
|
||||||
setResetLoading(false);
|
setResetLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -293,8 +308,9 @@ export function HomepageAuth({
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
setResetSuccess(true);
|
setResetSuccess(true);
|
||||||
|
toast.success(t("messages.passwordResetSuccess"));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err?.response?.data?.error || t("errors.failedCompleteReset"));
|
toast.error(err?.response?.data?.error || t("errors.failedCompleteReset"));
|
||||||
} finally {
|
} finally {
|
||||||
setResetLoading(false);
|
setResetLoading(false);
|
||||||
}
|
}
|
||||||
@@ -319,7 +335,7 @@ export function HomepageAuth({
|
|||||||
|
|
||||||
async function handleTOTPVerification() {
|
async function handleTOTPVerification() {
|
||||||
if (totpCode.length !== 6) {
|
if (totpCode.length !== 6) {
|
||||||
setError(t("auth.enterCode"));
|
toast.error(t("auth.enterCode"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,19 +356,20 @@ export function HomepageAuth({
|
|||||||
setLoggedIn(true);
|
setLoggedIn(true);
|
||||||
setIsAdmin(!!meRes.is_admin);
|
setIsAdmin(!!meRes.is_admin);
|
||||||
setUsername(meRes.username || null);
|
setUsername(meRes.username || null);
|
||||||
setUserId(meRes.id || null);
|
setUserId(meRes.userId || null);
|
||||||
setDbError(null);
|
setDbError(null);
|
||||||
onAuthSuccess({
|
onAuthSuccess({
|
||||||
isAdmin: !!meRes.is_admin,
|
isAdmin: !!meRes.is_admin,
|
||||||
username: meRes.username || null,
|
username: meRes.username || null,
|
||||||
userId: meRes.id || null,
|
userId: meRes.userId || null,
|
||||||
});
|
});
|
||||||
setInternalLoggedIn(true);
|
setInternalLoggedIn(true);
|
||||||
setTotpRequired(false);
|
setTotpRequired(false);
|
||||||
setTotpCode("");
|
setTotpCode("");
|
||||||
setTotpTempToken("");
|
setTotpTempToken("");
|
||||||
|
toast.success(t("messages.loginSuccess"));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(
|
toast.error(
|
||||||
err?.response?.data?.error ||
|
err?.response?.data?.error ||
|
||||||
err?.message ||
|
err?.message ||
|
||||||
t("errors.invalidTotpCode"),
|
t("errors.invalidTotpCode"),
|
||||||
@@ -375,7 +392,7 @@ export function HomepageAuth({
|
|||||||
|
|
||||||
window.location.replace(authUrl);
|
window.location.replace(authUrl);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(
|
toast.error(
|
||||||
err?.response?.data?.error ||
|
err?.response?.data?.error ||
|
||||||
err?.message ||
|
err?.message ||
|
||||||
t("errors.failedOidcLogin"),
|
t("errors.failedOidcLogin"),
|
||||||
@@ -391,7 +408,7 @@ export function HomepageAuth({
|
|||||||
const error = urlParams.get("error");
|
const error = urlParams.get("error");
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
setError(`${t("errors.oidcAuthFailed")}: ${error}`);
|
toast.error(`${t("errors.oidcAuthFailed")}: ${error}`);
|
||||||
setOidcLoading(false);
|
setOidcLoading(false);
|
||||||
window.history.replaceState({}, document.title, window.location.pathname);
|
window.history.replaceState({}, document.title, window.location.pathname);
|
||||||
return;
|
return;
|
||||||
@@ -408,12 +425,12 @@ export function HomepageAuth({
|
|||||||
setLoggedIn(true);
|
setLoggedIn(true);
|
||||||
setIsAdmin(!!meRes.is_admin);
|
setIsAdmin(!!meRes.is_admin);
|
||||||
setUsername(meRes.username || null);
|
setUsername(meRes.username || null);
|
||||||
setUserId(meRes.id || null);
|
setUserId(meRes.userId || null);
|
||||||
setDbError(null);
|
setDbError(null);
|
||||||
onAuthSuccess({
|
onAuthSuccess({
|
||||||
isAdmin: !!meRes.is_admin,
|
isAdmin: !!meRes.is_admin,
|
||||||
username: meRes.username || null,
|
username: meRes.username || null,
|
||||||
userId: meRes.id || null,
|
userId: meRes.userId || null,
|
||||||
});
|
});
|
||||||
setInternalLoggedIn(true);
|
setInternalLoggedIn(true);
|
||||||
window.history.replaceState(
|
window.history.replaceState(
|
||||||
@@ -423,7 +440,7 @@ export function HomepageAuth({
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
setError(t("errors.failedUserInfo"));
|
toast.error(t("errors.failedUserInfo"));
|
||||||
setInternalLoggedIn(false);
|
setInternalLoggedIn(false);
|
||||||
setLoggedIn(false);
|
setLoggedIn(false);
|
||||||
setIsAdmin(false);
|
setIsAdmin(false);
|
||||||
@@ -468,6 +485,34 @@ export function HomepageAuth({
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [currentServerUrl, setCurrentServerUrl] = useState<string>("");
|
const [currentServerUrl, setCurrentServerUrl] = useState<string>("");
|
||||||
|
const [dbConnectionFailed, setDbConnectionFailed] = useState(false);
|
||||||
|
const [dbHealthChecking, setDbHealthChecking] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (dbConnectionFailed) {
|
||||||
|
toast.error(t("errors.databaseConnection"));
|
||||||
|
}
|
||||||
|
}, [dbConnectionFailed, t]);
|
||||||
|
|
||||||
|
const retryDatabaseConnection = async () => {
|
||||||
|
setDbHealthChecking(true);
|
||||||
|
setDbConnectionFailed(false);
|
||||||
|
try {
|
||||||
|
const res = await getSetupRequired();
|
||||||
|
if (res.setup_required) {
|
||||||
|
setFirstUser(true);
|
||||||
|
setTab("signup");
|
||||||
|
} else {
|
||||||
|
setFirstUser(false);
|
||||||
|
}
|
||||||
|
setDbError(null);
|
||||||
|
toast.success(t("messages.databaseConnected"));
|
||||||
|
} catch (error) {
|
||||||
|
setDbConnectionFailed(true);
|
||||||
|
} finally {
|
||||||
|
setDbHealthChecking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkServerConfig = async () => {
|
const checkServerConfig = async () => {
|
||||||
@@ -519,42 +564,91 @@ export function HomepageAuth({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dbHealthChecking && !dbConnectionFailed) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`w-[420px] max-w-full p-6 flex flex-col bg-dark-bg border-2 border-dark-border rounded-md ${className || ""}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center h-32">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{t("common.checkingDatabase")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dbConnectionFailed) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`w-[420px] max-w-full p-6 flex flex-col bg-dark-bg border-2 border-dark-border rounded-md ${className || ""}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="mb-6 text-center">
|
||||||
|
<h2 className="text-xl font-bold mb-1">
|
||||||
|
{t("errors.databaseConnection")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{t("messages.databaseConnectionFailed")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full h-11 text-base font-semibold"
|
||||||
|
disabled={dbHealthChecking}
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
>
|
||||||
|
{t("common.refresh")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 pt-4 border-t border-dark-border space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm text-muted-foreground">
|
||||||
|
{t("common.language")}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<LanguageSwitcher />
|
||||||
|
</div>
|
||||||
|
{isElectron() && currentServerUrl && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm text-muted-foreground">
|
||||||
|
Server
|
||||||
|
</Label>
|
||||||
|
<div className="text-xs text-muted-foreground truncate max-w-[200px]">
|
||||||
|
{currentServerUrl}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowServerConfig(true)}
|
||||||
|
className="h-8 px-3"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`w-[420px] max-w-full p-6 flex flex-col bg-dark-bg border-2 border-dark-border rounded-md ${className || ""}`}
|
className={`w-[420px] max-w-full p-6 flex flex-col bg-dark-bg border-2 border-dark-border rounded-md ${className || ""}`}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{dbError && (
|
|
||||||
<Alert variant="destructive" className="mb-4">
|
|
||||||
<AlertTitle>Error</AlertTitle>
|
|
||||||
<AlertDescription>{dbError}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{firstUser && !dbError && !internalLoggedIn && (
|
|
||||||
<Alert variant="default" className="mb-4">
|
|
||||||
<AlertTitle>{t("auth.firstUser")}</AlertTitle>
|
|
||||||
<AlertDescription className="inline">
|
|
||||||
{t("auth.firstUserMessage")}{" "}
|
|
||||||
<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>{t("auth.registerTitle")}</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
{t("messages.registrationDisabled")}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{totpRequired && (
|
{totpRequired && (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<div className="mb-6 text-center">
|
<div className="mb-6 text-center">
|
||||||
@@ -805,14 +899,11 @@ export function HomepageAuth({
|
|||||||
|
|
||||||
{resetSuccess && (
|
{resetSuccess && (
|
||||||
<>
|
<>
|
||||||
<Alert className="mb-4">
|
<div className="text-center p-4 bg-green-500/10 rounded-lg border border-green-500/20 mb-4">
|
||||||
<AlertTitle>
|
<p className="text-green-400 text-sm">
|
||||||
{t("auth.passwordResetSuccess")}
|
|
||||||
</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
{t("auth.passwordResetSuccessDesc")}
|
{t("auth.passwordResetSuccessDesc")}
|
||||||
</AlertDescription>
|
</p>
|
||||||
</Alert>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full h-11 text-base font-semibold"
|
className="w-full h-11 text-base font-semibold"
|
||||||
@@ -998,12 +1089,6 @@ export function HomepageAuth({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{error && (
|
|
||||||
<Alert variant="destructive" className="mt-4">
|
|
||||||
<AlertTitle>Error</AlertTitle>
|
|
||||||
<AlertDescription>{error}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -361,8 +361,8 @@ export function LeftSidebar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hostsError && (
|
{hostsError && (
|
||||||
<div className="px-1">
|
<div className="!bg-dark-bg-input rounded-lg">
|
||||||
<div className="text-xs text-red-500 bg-red-500/10 rounded-lg px-2 py-1 border w-full">
|
<div className="w-full h-8 text-sm border-2 !bg-dark-bg-input border-dark-border rounded-md px-3 py-1.5 flex items-center text-red-500">
|
||||||
{t("leftSidebar.failedToLoadHosts")}
|
{t("leftSidebar.failedToLoadHosts")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -311,17 +311,17 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
window.location.port === "");
|
window.location.port === "");
|
||||||
|
|
||||||
const baseWsUrl = isDev
|
const baseWsUrl = isDev
|
||||||
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:8082`
|
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`
|
||||||
: isElectron()
|
: isElectron()
|
||||||
? (() => {
|
? (() => {
|
||||||
const baseUrl =
|
const baseUrl =
|
||||||
(window as any).configuredServerUrl ||
|
(window as any).configuredServerUrl ||
|
||||||
"http://127.0.0.1:8081";
|
"http://127.0.0.1:30001";
|
||||||
const wsProtocol = baseUrl.startsWith("https://")
|
const wsProtocol = baseUrl.startsWith("https://")
|
||||||
? "wss://"
|
? "wss://"
|
||||||
: "ws://";
|
: "ws://";
|
||||||
const wsHost = baseUrl.replace(/^https?:\/\//, "");
|
const wsHost = baseUrl.replace(/^https?:\/\//, "");
|
||||||
return `${wsProtocol}${wsHost.replace(':8081', ':8082')}/ssh/websocket/`;
|
return `${wsProtocol}${wsHost.replace(':30001', ':30002')}/ssh/websocket/`;
|
||||||
})()
|
})()
|
||||||
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/ssh/websocket/`;
|
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/ssh/websocket/`;
|
||||||
|
|
||||||
|
|||||||
@@ -283,7 +283,7 @@ function createApiInstance(
|
|||||||
// Handle DEK (Data Encryption Key) invalidation
|
// Handle DEK (Data Encryption Key) invalidation
|
||||||
if (status === 423) {
|
if (status === 423) {
|
||||||
const errorData = error.response?.data;
|
const errorData = error.response?.data;
|
||||||
if (errorData?.error === "DATA_LOCKED" || errorData?.message?.includes("DATA_LOCKED")) {
|
if ((errorData as any)?.error === "DATA_LOCKED" || (errorData as any)?.message?.includes("DATA_LOCKED")) {
|
||||||
// DEK session has expired (likely due to server restart or timeout)
|
// DEK session has expired (likely due to server restart or timeout)
|
||||||
// Force logout to require re-authentication and DEK unlock
|
// Force logout to require re-authentication and DEK unlock
|
||||||
if (isElectron()) {
|
if (isElectron()) {
|
||||||
@@ -324,11 +324,11 @@ function isDev(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let apiHost = import.meta.env.VITE_API_HOST || "localhost";
|
let apiHost = import.meta.env.VITE_API_HOST || "localhost";
|
||||||
let apiPort = 8081;
|
let apiPort = 30001;
|
||||||
let configuredServerUrl: string | null = null;
|
let configuredServerUrl: string | null = null;
|
||||||
|
|
||||||
if (isElectron()) {
|
if (isElectron()) {
|
||||||
apiPort = 8081;
|
apiPort = 30001;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerConfig {
|
export interface ServerConfig {
|
||||||
@@ -416,38 +416,38 @@ function getApiUrl(path: string, defaultPort: number): string {
|
|||||||
|
|
||||||
// Initialize API instances
|
// Initialize API instances
|
||||||
function initializeApiInstances() {
|
function initializeApiInstances() {
|
||||||
// SSH Host Management API (port 8081)
|
// SSH Host Management API (port 30001)
|
||||||
sshHostApi = createApiInstance(getApiUrl("/ssh", 8081), "SSH_HOST");
|
sshHostApi = createApiInstance(getApiUrl("/ssh", 30001), "SSH_HOST");
|
||||||
|
|
||||||
// Tunnel Management API (port 8083)
|
// Tunnel Management API (port 30003)
|
||||||
tunnelApi = createApiInstance(getApiUrl("/ssh", 8083), "TUNNEL");
|
tunnelApi = createApiInstance(getApiUrl("/ssh", 30003), "TUNNEL");
|
||||||
|
|
||||||
// File Manager Operations API (port 8084)
|
// File Manager Operations API (port 30004)
|
||||||
fileManagerApi = createApiInstance(
|
fileManagerApi = createApiInstance(
|
||||||
getApiUrl("/ssh/file_manager", 8084),
|
getApiUrl("/ssh/file_manager", 30004),
|
||||||
"FILE_MANAGER",
|
"FILE_MANAGER",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Server Statistics API (port 8085)
|
// Server Statistics API (port 30005)
|
||||||
statsApi = createApiInstance(getApiUrl("", 8085), "STATS");
|
statsApi = createApiInstance(getApiUrl("", 30005), "STATS");
|
||||||
|
|
||||||
// Authentication API (port 8081)
|
// Authentication API (port 30001)
|
||||||
authApi = createApiInstance(getApiUrl("", 8081), "AUTH");
|
authApi = createApiInstance(getApiUrl("", 30001), "AUTH");
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSH Host Management API (port 8081)
|
// SSH Host Management API (port 30001)
|
||||||
export let sshHostApi: AxiosInstance;
|
export let sshHostApi: AxiosInstance;
|
||||||
|
|
||||||
// Tunnel Management API (port 8083)
|
// Tunnel Management API (port 30003)
|
||||||
export let tunnelApi: AxiosInstance;
|
export let tunnelApi: AxiosInstance;
|
||||||
|
|
||||||
// File Manager Operations API (port 8084)
|
// File Manager Operations API (port 30004)
|
||||||
export let fileManagerApi: AxiosInstance;
|
export let fileManagerApi: AxiosInstance;
|
||||||
|
|
||||||
// Server Statistics API (port 8085)
|
// Server Statistics API (port 30005)
|
||||||
export let statsApi: AxiosInstance;
|
export let statsApi: AxiosInstance;
|
||||||
|
|
||||||
// Authentication API (port 8081)
|
// Authentication API (port 30001)
|
||||||
export let authApi: AxiosInstance;
|
export let authApi: AxiosInstance;
|
||||||
|
|
||||||
// Initialize API instances immediately
|
// Initialize API instances immediately
|
||||||
@@ -1763,11 +1763,9 @@ export async function generateBackupCodes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUserAlerts(
|
export async function getUserAlerts(): Promise<{ alerts: any[] }> {
|
||||||
userId: string,
|
|
||||||
): Promise<{ alerts: any[] }> {
|
|
||||||
try {
|
try {
|
||||||
const response = await authApi.get(`/alerts/user/${userId}`);
|
const response = await authApi.get(`/alerts`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "fetch user alerts");
|
handleApiError(error, "fetch user alerts");
|
||||||
@@ -1775,11 +1773,10 @@ export async function getUserAlerts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function dismissAlert(
|
export async function dismissAlert(
|
||||||
userId: string,
|
|
||||||
alertId: string,
|
alertId: string,
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.post("/alerts/dismiss", { userId, alertId });
|
const response = await authApi.post("/alerts/dismiss", { alertId });
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "dismiss alert");
|
handleApiError(error, "dismiss alert");
|
||||||
|
|||||||
Reference in New Issue
Block a user