Compare commits
36 Commits
release-1.
...
starhound/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
247c1b5c0a | ||
|
|
776f581377 | ||
|
|
3ac7ad0bd7 | ||
|
|
bc6264bb50 | ||
|
|
5d61112a4e | ||
|
|
d047beab13 | ||
|
|
f2285b1abb | ||
|
|
48933e9b11 | ||
|
|
3248b2336b | ||
|
|
4b4bff4b29 | ||
|
|
2f092bd367 | ||
|
|
42e27e7389 | ||
|
|
aa1476fc13 | ||
|
|
7c9762562b | ||
|
|
a84eb5636e | ||
|
|
65466bc3f9 | ||
|
|
208110a433 | ||
|
|
a98359ebc1 | ||
|
|
05a1b3bfaf | ||
|
|
dfb9e7afe7 | ||
|
|
e405f8a6fa | ||
|
|
f8de3369c3 | ||
|
|
150d5796f8 | ||
|
|
18f31ade1e | ||
|
|
4863776f9b | ||
|
|
84c7b9f9fc | ||
|
|
2754585988 | ||
|
|
a06e62b81a | ||
|
|
69dfebab37 | ||
|
|
4da2b985ad | ||
|
|
b57cc52c94 | ||
|
|
757d0c246d | ||
|
|
7975a077ea | ||
|
|
8366c99b0f | ||
|
|
38a59f3579 | ||
|
|
9ca7df6542 |
3
.github/workflows/docker.yml
vendored
3
.github/workflows/docker.yml
vendored
@@ -84,7 +84,8 @@ jobs:
|
||||
labels: |
|
||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
outputs: type=registry,compression=zstd,compression-level=19
|
||||
org.opencontainers.image.created=${{ github.run_id }}
|
||||
outputs: type=registry,compression=gzip,compression-level=9
|
||||
|
||||
- name: Cleanup Docker
|
||||
if: always()
|
||||
|
||||
405
.github/workflows/electron.yml
vendored
405
.github/workflows/electron.yml
vendored
@@ -27,7 +27,7 @@ on:
|
||||
jobs:
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
if: github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'windows' || github.event.inputs.build_type == ''
|
||||
if: (github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'windows' || github.event.inputs.build_type == '') && github.event.inputs.artifact_destination != 'submit'
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -72,10 +72,6 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: npm run build && npx electron-builder --win --x64 --ia32
|
||||
|
||||
- name: List release files
|
||||
run: |
|
||||
dir release
|
||||
|
||||
- name: Upload Windows x64 NSIS Installer
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('release/termix_windows_x64_nsis.exe') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
@@ -136,7 +132,7 @@ jobs:
|
||||
|
||||
build-linux:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
if: github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'linux' || github.event.inputs.build_type == ''
|
||||
if: (github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'linux' || github.event.inputs.build_type == '') && github.event.inputs.artifact_destination != 'submit'
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -199,17 +195,6 @@ jobs:
|
||||
|
||||
cd ..
|
||||
|
||||
- name: List release files
|
||||
run: |
|
||||
ls -la release/
|
||||
|
||||
- name: Debug electron-builder output
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f "release/builder-debug.yml" ]; then
|
||||
cat release/builder-debug.yml
|
||||
fi
|
||||
|
||||
- name: Upload Linux x64 AppImage
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('release/termix_linux_x64_appimage.AppImage') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
@@ -282,6 +267,93 @@ jobs:
|
||||
path: release/termix_linux_armv7l_portable.tar.gz
|
||||
retention-days: 30
|
||||
|
||||
- name: Install Flatpak builder and dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y flatpak flatpak-builder imagemagick
|
||||
|
||||
- name: Add Flathub repository
|
||||
run: |
|
||||
sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
|
||||
- name: Install Flatpak runtime and SDK
|
||||
run: |
|
||||
sudo flatpak install -y flathub org.freedesktop.Platform//24.08
|
||||
sudo flatpak install -y flathub org.freedesktop.Sdk//24.08
|
||||
sudo flatpak install -y flathub org.electronjs.Electron2.BaseApp//24.08
|
||||
|
||||
- name: Get version for Flatpak
|
||||
id: flatpak-version
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
RELEASE_DATE=$(date +%Y-%m-%d)
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "release_date=$RELEASE_DATE" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Prepare Flatpak files
|
||||
run: |
|
||||
VERSION="${{ steps.flatpak-version.outputs.version }}"
|
||||
RELEASE_DATE="${{ steps.flatpak-version.outputs.release_date }}"
|
||||
|
||||
CHECKSUM_X64=$(sha256sum "release/termix_linux_x64_appimage.AppImage" | awk '{print $1}')
|
||||
CHECKSUM_ARM64=$(sha256sum "release/termix_linux_arm64_appimage.AppImage" | awk '{print $1}')
|
||||
|
||||
mkdir -p flatpak-build
|
||||
cp flatpak/com.karmaa.termix.yml flatpak-build/
|
||||
cp flatpak/com.karmaa.termix.desktop flatpak-build/
|
||||
cp flatpak/com.karmaa.termix.metainfo.xml flatpak-build/
|
||||
cp public/icon.svg flatpak-build/com.karmaa.termix.svg
|
||||
convert public/icon.png -resize 256x256 flatpak-build/icon-256.png
|
||||
convert public/icon.png -resize 128x128 flatpak-build/icon-128.png
|
||||
|
||||
cd flatpak-build
|
||||
sed -i "s|https://github.com/Termix-SSH/Termix/releases/download/release-VERSION_PLACEHOLDER-tag/termix_linux_x64_appimage.AppImage|file://$(realpath ../release/termix_linux_x64_appimage.AppImage)|g" com.karmaa.termix.yml
|
||||
sed -i "s|https://github.com/Termix-SSH/Termix/releases/download/release-VERSION_PLACEHOLDER-tag/termix_linux_arm64_appimage.AppImage|file://$(realpath ../release/termix_linux_arm64_appimage.AppImage)|g" com.karmaa.termix.yml
|
||||
sed -i "s/CHECKSUM_X64_PLACEHOLDER/$CHECKSUM_X64/g" com.karmaa.termix.yml
|
||||
sed -i "s/CHECKSUM_ARM64_PLACEHOLDER/$CHECKSUM_ARM64/g" com.karmaa.termix.yml
|
||||
sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" com.karmaa.termix.metainfo.xml
|
||||
sed -i "s/DATE_PLACEHOLDER/$RELEASE_DATE/g" com.karmaa.termix.metainfo.xml
|
||||
|
||||
- name: Build Flatpak bundle
|
||||
run: |
|
||||
cd flatpak-build
|
||||
flatpak-builder --repo=repo --force-clean --disable-rofiles-fuse build-dir com.karmaa.termix.yml
|
||||
|
||||
# Determine the architecture
|
||||
ARCH=$(uname -m)
|
||||
if [ "$ARCH" = "x86_64" ]; then
|
||||
FLATPAK_ARCH="x86_64"
|
||||
elif [ "$ARCH" = "aarch64" ]; then
|
||||
FLATPAK_ARCH="aarch64"
|
||||
else
|
||||
FLATPAK_ARCH="$ARCH"
|
||||
fi
|
||||
|
||||
# Build bundle for the current architecture
|
||||
flatpak build-bundle repo ../release/termix_linux_flatpak.flatpak com.karmaa.termix --runtime-repo=https://flathub.org/repo/flathub.flatpakrepo
|
||||
|
||||
- name: Create flatpakref file
|
||||
run: |
|
||||
VERSION="${{ steps.flatpak-version.outputs.version }}"
|
||||
cp flatpak/com.karmaa.termix.flatpakref release/
|
||||
sed -i "s|VERSION_PLACEHOLDER|release-${VERSION}-tag|g" release/com.karmaa.termix.flatpakref
|
||||
|
||||
- name: Upload Flatpak bundle
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('release/termix_linux_flatpak.flatpak') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_linux_flatpak
|
||||
path: release/termix_linux_flatpak.flatpak
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Flatpakref
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('release/com.karmaa.termix.flatpakref') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_linux_flatpakref
|
||||
path: release/com.karmaa.termix.flatpakref
|
||||
retention-days: 30
|
||||
|
||||
build-macos:
|
||||
runs-on: macos-latest
|
||||
if: github.event.inputs.build_type == 'macos' || github.event.inputs.build_type == 'all'
|
||||
@@ -425,11 +497,6 @@ jobs:
|
||||
export GH_TOKEN="${{ secrets.GITHUB_TOKEN }}"
|
||||
npx electron-builder --mac dmg --universal --x64 --arm64 --publish never
|
||||
|
||||
- name: List release directory
|
||||
if: steps.check_certs.outputs.has_certs == 'true'
|
||||
run: |
|
||||
ls -R release/ || echo "Release directory not found"
|
||||
|
||||
- name: Upload macOS MAS PKG
|
||||
if: steps.check_certs.outputs.has_certs == 'true' && hashFiles('release/termix_macos_universal_mas.pkg') != '' && (github.event.inputs.artifact_destination == 'file' || github.event.inputs.artifact_destination == 'release' || github.event.inputs.artifact_destination == 'submit')
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -463,42 +530,51 @@ jobs:
|
||||
path: release/termix_macos_arm64_dmg.dmg
|
||||
retention-days: 30
|
||||
|
||||
- name: Check for App Store Connect API credentials
|
||||
if: steps.check_certs.outputs.has_certs == 'true'
|
||||
id: check_asc_creds
|
||||
- name: Get version for Homebrew
|
||||
id: homebrew-version
|
||||
run: |
|
||||
if [ -n "${{ secrets.APPLE_KEY_ID }}" ] && [ -n "${{ secrets.APPLE_ISSUER_ID }}" ] && [ -n "${{ secrets.APPLE_KEY_CONTENT }}" ]; then
|
||||
echo "has_credentials=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup Ruby for Fastlane
|
||||
if: steps.check_asc_creds.outputs.has_credentials == 'true' && github.event.inputs.artifact_destination == 'submit'
|
||||
uses: ruby/setup-ruby@v1
|
||||
- name: Generate Homebrew Cask
|
||||
if: hashFiles('release/termix_macos_universal_dmg.dmg') != '' && (github.event.inputs.artifact_destination == 'file' || github.event.inputs.artifact_destination == 'release')
|
||||
run: |
|
||||
VERSION="${{ steps.homebrew-version.outputs.version }}"
|
||||
DMG_PATH="release/termix_macos_universal_dmg.dmg"
|
||||
|
||||
CHECKSUM=$(shasum -a 256 "$DMG_PATH" | awk '{print $1}')
|
||||
|
||||
mkdir -p homebrew-generated
|
||||
cp homebrew/termix.rb homebrew-generated/termix.rb
|
||||
|
||||
sed -i '' "s/VERSION_PLACEHOLDER/$VERSION/g" homebrew-generated/termix.rb
|
||||
sed -i '' "s/CHECKSUM_PLACEHOLDER/$CHECKSUM/g" homebrew-generated/termix.rb
|
||||
sed -i '' "s|version \".*\"|version \"$VERSION\"|g" homebrew-generated/termix.rb
|
||||
sed -i '' "s|sha256 \".*\"|sha256 \"$CHECKSUM\"|g" homebrew-generated/termix.rb
|
||||
sed -i '' "s|release-[0-9.]*-tag|release-$VERSION-tag|g" homebrew-generated/termix.rb
|
||||
|
||||
- name: Upload Homebrew Cask as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('homebrew-generated/termix.rb') != '' && github.event.inputs.artifact_destination == 'file'
|
||||
with:
|
||||
ruby-version: "3.2"
|
||||
bundler-cache: false
|
||||
name: termix_macos_homebrew_cask
|
||||
path: homebrew-generated/termix.rb
|
||||
retention-days: 30
|
||||
|
||||
- name: Install Fastlane
|
||||
if: steps.check_asc_creds.outputs.has_credentials == 'true' && github.event.inputs.artifact_destination == 'submit'
|
||||
- name: Upload Homebrew Cask to release
|
||||
if: hashFiles('homebrew-generated/termix.rb') != '' && github.event.inputs.artifact_destination == 'release'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gem install fastlane -N
|
||||
VERSION="${{ steps.homebrew-version.outputs.version }}"
|
||||
RELEASE_TAG="release-$VERSION-tag"
|
||||
|
||||
- name: Deploy to App Store Connect (TestFlight)
|
||||
if: steps.check_asc_creds.outputs.has_credentials == 'true' && github.event.inputs.artifact_destination == 'submit'
|
||||
run: |
|
||||
PKG_FILE=$(find release -name "*.pkg" -type f | head -n 1)
|
||||
if [ -z "$PKG_FILE" ]; then
|
||||
gh release list --repo ${{ github.repository }} --limit 100 | grep -q "$RELEASE_TAG" || {
|
||||
echo "Release $RELEASE_TAG not found"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
mkdir -p ~/private_keys
|
||||
echo "${{ secrets.APPLE_KEY_CONTENT }}" | base64 --decode > ~/private_keys/AuthKey_${{ secrets.APPLE_KEY_ID }}.p8
|
||||
|
||||
xcrun altool --upload-app -f "$PKG_FILE" \
|
||||
--type macos \
|
||||
--apiKey "${{ secrets.APPLE_KEY_ID }}" \
|
||||
--apiIssuer "${{ secrets.APPLE_ISSUER_ID }}"
|
||||
continue-on-error: true
|
||||
gh release upload "$RELEASE_TAG" homebrew-generated/termix.rb --repo ${{ github.repository }} --clobber
|
||||
|
||||
- name: Clean up keychains
|
||||
if: always()
|
||||
@@ -509,7 +585,6 @@ jobs:
|
||||
submit-to-chocolatey:
|
||||
runs-on: windows-latest
|
||||
if: github.event.inputs.artifact_destination == 'submit'
|
||||
needs: [build-windows]
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -525,20 +600,25 @@ jobs:
|
||||
$VERSION = (Get-Content package.json | ConvertFrom-Json).version
|
||||
echo "version=$VERSION" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- name: Download Windows x64 MSI artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: termix_windows_x64_msi
|
||||
path: artifact
|
||||
|
||||
- name: Get MSI file info
|
||||
- name: Download and prepare MSI info from public release
|
||||
id: msi-info
|
||||
run: |
|
||||
$VERSION = "${{ steps.package-version.outputs.version }}"
|
||||
$MSI_FILE = Get-ChildItem -Path artifact -Filter "*.msi" | Select-Object -First 1
|
||||
$MSI_NAME = $MSI_FILE.Name
|
||||
$CHECKSUM = (Get-FileHash -Path $MSI_FILE.FullName -Algorithm SHA256).Hash
|
||||
$MSI_NAME = "termix_windows_x64_msi.msi"
|
||||
$DOWNLOAD_URL = "https://github.com/Termix-SSH/Termix/releases/download/release-$($VERSION)-tag/$($MSI_NAME)"
|
||||
|
||||
Write-Host "Downloading from $DOWNLOAD_URL"
|
||||
New-Item -ItemType Directory -Force -Path "release_asset"
|
||||
$DOWNLOAD_PATH = "release_asset\$MSI_NAME"
|
||||
|
||||
try {
|
||||
Invoke-WebRequest -Uri $DOWNLOAD_URL -OutFile $DOWNLOAD_PATH -UseBasicParsing
|
||||
} catch {
|
||||
Write-Error "Failed to download MSI from $DOWNLOAD_URL. Please ensure the release and asset exist."
|
||||
exit 1
|
||||
}
|
||||
|
||||
$CHECKSUM = (Get-FileHash -Path $DOWNLOAD_PATH -Algorithm SHA256).Hash
|
||||
echo "msi_name=$MSI_NAME" >> $env:GITHUB_OUTPUT
|
||||
echo "checksum=$CHECKSUM" >> $env:GITHUB_OUTPUT
|
||||
|
||||
@@ -610,7 +690,7 @@ jobs:
|
||||
submit-to-flatpak:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.inputs.artifact_destination == 'submit'
|
||||
needs: [build-linux]
|
||||
needs: []
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -628,30 +708,27 @@ jobs:
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "release_date=$RELEASE_DATE" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download Linux x64 AppImage artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: termix_linux_x64_appimage
|
||||
path: artifact-x64
|
||||
|
||||
- name: Download Linux arm64 AppImage artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: termix_linux_arm64_appimage
|
||||
path: artifact-arm64
|
||||
|
||||
- name: Get AppImage file info
|
||||
- name: Download and prepare AppImage info from public release
|
||||
id: appimage-info
|
||||
run: |
|
||||
VERSION="${{ steps.package-version.outputs.version }}"
|
||||
mkdir -p release_assets
|
||||
|
||||
APPIMAGE_X64_FILE=$(find artifact-x64 -name "*.AppImage" -type f | head -n 1)
|
||||
APPIMAGE_X64_NAME=$(basename "$APPIMAGE_X64_FILE")
|
||||
CHECKSUM_X64=$(sha256sum "$APPIMAGE_X64_FILE" | awk '{print $1}')
|
||||
APPIMAGE_X64_NAME="termix_linux_x64_appimage.AppImage"
|
||||
URL_X64="https://github.com/Termix-SSH/Termix/releases/download/release-$VERSION-tag/$APPIMAGE_X64_NAME"
|
||||
PATH_X64="release_assets/$APPIMAGE_X64_NAME"
|
||||
echo "Downloading x64 AppImage from $URL_X64"
|
||||
curl -L -o "$PATH_X64" "$URL_X64"
|
||||
chmod +x "$PATH_X64"
|
||||
CHECKSUM_X64=$(sha256sum "$PATH_X64" | awk '{print $1}')
|
||||
|
||||
APPIMAGE_ARM64_FILE=$(find artifact-arm64 -name "*.AppImage" -type f | head -n 1)
|
||||
APPIMAGE_ARM64_NAME=$(basename "$APPIMAGE_ARM64_FILE")
|
||||
CHECKSUM_ARM64=$(sha256sum "$APPIMAGE_ARM64_FILE" | awk '{print $1}')
|
||||
APPIMAGE_ARM64_NAME="termix_linux_arm64_appimage.AppImage"
|
||||
URL_ARM64="https://github.com/Termix-SSH/Termix/releases/download/release-$VERSION-tag/$APPIMAGE_ARM64_NAME"
|
||||
PATH_ARM64="release_assets/$APPIMAGE_ARM64_NAME"
|
||||
echo "Downloading arm64 AppImage from $URL_ARM64"
|
||||
curl -L -o "$PATH_ARM64" "$URL_ARM64"
|
||||
chmod +x "$PATH_ARM64"
|
||||
CHECKSUM_ARM64=$(sha256sum "$PATH_ARM64" | awk '{print $1}')
|
||||
|
||||
echo "appimage_x64_name=$APPIMAGE_X64_NAME" >> $GITHUB_OUTPUT
|
||||
echo "checksum_x64=$CHECKSUM_X64" >> $GITHUB_OUTPUT
|
||||
@@ -690,10 +767,6 @@ jobs:
|
||||
sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" flatpak-submission/com.karmaa.termix.metainfo.xml
|
||||
sed -i "s/DATE_PLACEHOLDER/$RELEASE_DATE/g" flatpak-submission/com.karmaa.termix.metainfo.xml
|
||||
|
||||
- name: List submission files
|
||||
run: |
|
||||
ls -la flatpak-submission/
|
||||
|
||||
- name: Upload Flatpak submission as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -704,7 +777,7 @@ jobs:
|
||||
submit-to-homebrew:
|
||||
runs-on: macos-latest
|
||||
if: github.event.inputs.artifact_destination == 'submit'
|
||||
needs: [build-macos]
|
||||
needs: []
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -720,19 +793,19 @@ jobs:
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download macOS Universal DMG artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: termix_macos_universal_dmg
|
||||
path: artifact
|
||||
|
||||
- name: Get DMG file info
|
||||
- name: Download and prepare DMG info from public release
|
||||
id: dmg-info
|
||||
run: |
|
||||
VERSION="${{ steps.package-version.outputs.version }}"
|
||||
DMG_FILE=$(find artifact -name "*.dmg" -type f | head -n 1)
|
||||
DMG_NAME=$(basename "$DMG_FILE")
|
||||
CHECKSUM=$(shasum -a 256 "$DMG_FILE" | awk '{print $1}')
|
||||
DMG_NAME="termix_macos_universal_dmg.dmg"
|
||||
URL="https://github.com/Termix-SSH/Termix/releases/download/release-$VERSION-tag/$DMG_NAME"
|
||||
|
||||
mkdir -p release_asset
|
||||
PATH="release_asset/$DMG_NAME"
|
||||
echo "Downloading DMG from $URL"
|
||||
curl -L -o "$PATH" "$URL"
|
||||
|
||||
CHECKSUM=$(shasum -a 256 "$PATH" | awk '{print $1}')
|
||||
|
||||
echo "dmg_name=$DMG_NAME" >> $GITHUB_OUTPUT
|
||||
echo "checksum=$CHECKSUM" >> $GITHUB_OUTPUT
|
||||
@@ -746,23 +819,14 @@ jobs:
|
||||
mkdir -p homebrew-submission/Casks/t
|
||||
|
||||
cp homebrew/termix.rb homebrew-submission/Casks/t/termix.rb
|
||||
cp homebrew/README.md homebrew-submission/
|
||||
|
||||
sed -i '' "s/VERSION_PLACEHOLDER/$VERSION/g" homebrew-submission/Casks/t/termix.rb
|
||||
sed -i '' "s/CHECKSUM_PLACEHOLDER/$CHECKSUM/g" homebrew-submission/Casks/t/termix.rb
|
||||
|
||||
- name: Verify Cask syntax
|
||||
run: |
|
||||
if ! command -v brew &> /dev/null; then
|
||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
fi
|
||||
|
||||
ruby -c homebrew-submission/Casks/t/termix.rb
|
||||
|
||||
- name: List submission files
|
||||
run: |
|
||||
find homebrew-submission -type f
|
||||
|
||||
- name: Upload Homebrew submission as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -790,10 +854,6 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Display artifact structure
|
||||
run: |
|
||||
ls -R artifacts/
|
||||
|
||||
- name: Upload artifacts to latest release
|
||||
run: |
|
||||
cd artifacts
|
||||
@@ -809,3 +869,130 @@ jobs:
|
||||
done
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
submit-to-testflight:
|
||||
runs-on: macos-latest
|
||||
if: github.event.inputs.artifact_destination == 'submit'
|
||||
needs: []
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
for i in 1 2 3;
|
||||
do
|
||||
if npm ci; then
|
||||
break
|
||||
else
|
||||
if [ $i -eq 3 ]; then
|
||||
exit 1
|
||||
fi
|
||||
sleep 10
|
||||
fi
|
||||
done
|
||||
npm install --force @rollup/rollup-darwin-arm64
|
||||
npm install dmg-license
|
||||
|
||||
- name: Check for Code Signing Certificates
|
||||
id: check_certs
|
||||
run: |
|
||||
if [ -n "${{ secrets.MAC_BUILD_CERTIFICATE_BASE64 }}" ] && [ -n "${{ secrets.MAC_P12_PASSWORD }}" ]; then
|
||||
echo "has_certs=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Import Code Signing Certificates
|
||||
if: steps.check_certs.outputs.has_certs == 'true'
|
||||
env:
|
||||
MAC_BUILD_CERTIFICATE_BASE64: ${{ secrets.MAC_BUILD_CERTIFICATE_BASE64 }}
|
||||
MAC_INSTALLER_CERTIFICATE_BASE64: ${{ secrets.MAC_INSTALLER_CERTIFICATE_BASE64 }}
|
||||
MAC_P12_PASSWORD: ${{ secrets.MAC_P12_PASSWORD }}
|
||||
MAC_KEYCHAIN_PASSWORD: ${{ secrets.MAC_KEYCHAIN_PASSWORD }}
|
||||
run: |
|
||||
APP_CERT_PATH=$RUNNER_TEMP/app_certificate.p12
|
||||
INSTALLER_CERT_PATH=$RUNNER_TEMP/installer_certificate.p12
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
|
||||
echo -n "$MAC_BUILD_CERTIFICATE_BASE64" | base64 --decode -o $APP_CERT_PATH
|
||||
|
||||
if [ -n "$MAC_INSTALLER_CERTIFICATE_BASE64" ]; then
|
||||
echo -n "$MAC_INSTALLER_CERTIFICATE_BASE64" | base64 --decode -o $INSTALLER_CERT_PATH
|
||||
fi
|
||||
|
||||
security create-keychain -p "$MAC_KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
||||
security unlock-keychain -p "$MAC_KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
|
||||
security import $APP_CERT_PATH -P "$MAC_P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
||||
|
||||
if [ -f "$INSTALLER_CERT_PATH" ]; then
|
||||
security import $INSTALLER_CERT_PATH -P "$MAC_P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
||||
fi
|
||||
|
||||
security list-keychain -d user -s $KEYCHAIN_PATH
|
||||
|
||||
security find-identity -v -p codesigning $KEYCHAIN_PATH
|
||||
|
||||
- name: Build macOS App Store Package
|
||||
if: steps.check_certs.outputs.has_certs == 'true'
|
||||
env:
|
||||
ELECTRON_BUILDER_ALLOW_UNRESOLVED_DEPENDENCIES: true
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
CURRENT_VERSION=$(node -p "require('./package.json').version")
|
||||
BUILD_VERSION="${{ github.run_number }}"
|
||||
|
||||
npm run build && npx electron-builder --mac mas --universal --config.buildVersion="$BUILD_VERSION"
|
||||
|
||||
- name: Check for App Store Connect API credentials
|
||||
id: check_asc_creds
|
||||
run: |
|
||||
if [ -n "${{ secrets.APPLE_KEY_ID }}" ] && [ -n "${{ secrets.APPLE_ISSUER_ID }}" ] && [ -n "${{ secrets.APPLE_KEY_CONTENT }}" ]; then
|
||||
echo "has_credentials=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Setup Ruby for Fastlane
|
||||
if: steps.check_asc_creds.outputs.has_credentials == 'true'
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: "3.2"
|
||||
bundler-cache: false
|
||||
|
||||
- name: Install Fastlane
|
||||
if: steps.check_asc_creds.outputs.has_credentials == 'true'
|
||||
run: |
|
||||
gem install fastlane -N
|
||||
|
||||
- name: Deploy to App Store Connect (TestFlight)
|
||||
if: steps.check_asc_creds.outputs.has_credentials == 'true'
|
||||
run: |
|
||||
PKG_FILE=$(find artifact-mas -name "*.pkg" -type f | head -n 1)
|
||||
if [ -z "$PKG_FILE" ]; then
|
||||
echo "PKG file not found, exiting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p ~/private_keys
|
||||
echo "${{ secrets.APPLE_KEY_CONTENT }}" | base64 --decode > ~/private_keys/AuthKey_${{ secrets.APPLE_KEY_ID }}.p8
|
||||
|
||||
xcrun altool --upload-app -f "$PKG_FILE" \
|
||||
--type macos \
|
||||
--apiKey "${{ secrets.APPLE_KEY_ID }}" \
|
||||
--apiIssuer "${{ secrets.APPLE_ISSUER_ID }}"
|
||||
continue-on-error: true
|
||||
|
||||
- name: Clean up keychains
|
||||
if: always()
|
||||
run: |
|
||||
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
cask "termix" do
|
||||
version "VERSION_PLACEHOLDER"
|
||||
sha256 "CHECKSUM_PLACEHOLDER"
|
||||
version "1.9.0"
|
||||
sha256 "8fedd242b3cae1ebfd0c391a36f1c246a26ecac258b02478ee8dea2f33cd6d96"
|
||||
|
||||
url "https://github.com/Termix-SSH/Termix/releases/download/release-#{version}-tag/termix_macos_universal_#{version}_dmg.dmg"
|
||||
url "https://github.com/Termix-SSH/Termix/releases/download/release-#{version}-tag/termix_macos_universal_dmg.dmg"
|
||||
name "Termix"
|
||||
desc "Web-based server management platform with SSH terminal, tunneling, and file editing"
|
||||
homepage "https://github.com/Termix-SSH/Termix"
|
||||
61
DOWNLOADS.md
61
DOWNLOADS.md
@@ -1,61 +0,0 @@
|
||||
# Termix Download Links
|
||||
|
||||
## Windows
|
||||
|
||||
| Architecture | Type | Download Link |
|
||||
| ------------ | -------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| x64 | NSIS | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_x64_nsis.exe) |
|
||||
| x64 | MSI | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_x64_msi.msi) |
|
||||
| x64 | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_x64_portable.zip) |
|
||||
| ia32 | NSIS | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_ia32_nsis.exe) |
|
||||
| ia32 | MSI | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_ia32_msi.msi) |
|
||||
| ia32 | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_ia32_portable.zip) |
|
||||
|
||||
## Linux
|
||||
|
||||
| Architecture | Type | Download Link |
|
||||
| ------------ | -------- | --------------------------------------------------------------------------------------------------------------- |
|
||||
| x64 | AppImage | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_x64_appimage.AppImage) |
|
||||
| x64 | DEB | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_x64_deb.deb) |
|
||||
| x64 | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_x64_portable.tar.gz) |
|
||||
| arm64 | AppImage | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_arm64_appimage.AppImage) |
|
||||
| arm64 | DEB | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_arm64_deb.deb) |
|
||||
| arm64 | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_arm64_portable.tar.gz) |
|
||||
| armv7l | AppImage | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_armv7l_appimage.AppImage) |
|
||||
| armv7l | DEB | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_armv7l_deb.deb) |
|
||||
| armv7l | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_armv7l_portable.tar.gz) |
|
||||
|
||||
## macOS
|
||||
|
||||
| Architecture | Type | Download Link |
|
||||
| ------------ | ------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| Universal | DMG | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_macos_universal_dmg.dmg) |
|
||||
| Universal | Mac App Store | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_macos_universal_mas.pkg) |
|
||||
| x64 | DMG | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_macos_x64_dmg.dmg) |
|
||||
| arm64 | DMG | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_macos_arm64_dmg.dmg) |
|
||||
|
||||
---
|
||||
|
||||
## All Platforms - Complete Download List
|
||||
|
||||
| Platform | Architecture | Type | Download Link |
|
||||
| -------- | ------------ | ------------- | --------------------------------------------------------------------------------------------------------------- |
|
||||
| Windows | x64 | NSIS | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_x64_nsis.exe) |
|
||||
| Windows | x64 | MSI | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_x64_msi.msi) |
|
||||
| Windows | x64 | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_x64_portable.zip) |
|
||||
| Windows | ia32 | NSIS | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_ia32_nsis.exe) |
|
||||
| Windows | ia32 | MSI | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_ia32_msi.msi) |
|
||||
| Windows | ia32 | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_ia32_portable.zip) |
|
||||
| Linux | x64 | AppImage | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_x64_appimage.AppImage) |
|
||||
| Linux | x64 | DEB | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_x64_deb.deb) |
|
||||
| Linux | x64 | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_x64_portable.tar.gz) |
|
||||
| Linux | arm64 | AppImage | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_arm64_appimage.AppImage) |
|
||||
| Linux | arm64 | DEB | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_arm64_deb.deb) |
|
||||
| Linux | arm64 | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_arm64_portable.tar.gz) |
|
||||
| Linux | armv7l | AppImage | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_armv7l_appimage.AppImage) |
|
||||
| Linux | armv7l | DEB | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_armv7l_deb.deb) |
|
||||
| Linux | armv7l | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_armv7l_portable.tar.gz) |
|
||||
| macOS | Universal | DMG | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_macos_universal_dmg.dmg) |
|
||||
| macOS | Universal | Mac App Store | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_macos_universal_mas.pkg) |
|
||||
| macOS | x64 | DMG | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_macos_x64_dmg.dmg) |
|
||||
| macOS | arm64 | DMG | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_macos_arm64_dmg.dmg) |
|
||||
15
README-CN.md
15
README-CN.md
@@ -54,14 +54,17 @@ Termix 是一个开源、永久免费、自托管的一体化服务器管理平
|
||||
- **SSH 主机管理器** - 保存、组织和管理您的 SSH 连接,支持标签和文件夹,并轻松保存可重用的登录信息,同时能够自动部署 SSH 密钥
|
||||
- **服务器统计** - 在任何 SSH 服务器上查看 CPU、内存和磁盘使用情况以及网络、正常运行时间和系统信息
|
||||
- **仪表板** - 在仪表板上一目了然地查看服务器信息
|
||||
- **用户认证** - 安全的用户管理,具有管理员控制以及 OIDC 和 2FA (TOTP) 支持。查看所有平台上的活动用户会话并撤销权限。
|
||||
- **数据库加密** - 后端存储为加密的 SQLite 数据库文件
|
||||
- **用户认证** - 安全的用户管理,具有管理员控制以及 OIDC 和 2FA (TOTP) 支持。查看所有平台上的活动用户会话并撤销权限。将您的 OIDC/本地帐户链接在一起。
|
||||
- **数据库加密** - 后端存储为加密的 SQLite 数据库文件。查看[文档](https://docs.termix.site/security)了解更多信息。
|
||||
- **数据导出/导入** - 导出和导入 SSH 主机、凭据和文件管理器数据
|
||||
- **自动 SSL 设置** - 内置 SSL 证书生成和管理,支持 HTTPS 重定向
|
||||
- **现代用户界面** - 使用 React、Tailwind CSS 和 Shadcn 构建的简洁的桌面/移动设备友好界面
|
||||
- **语言** - 内置支持英语、中文、德语和葡萄牙语
|
||||
- **平台支持** - 可作为 Web 应用程序、桌面应用程序(Windows、Linux 和 macOS)以及适用于 iOS 和 Android 的专用移动/平板电脑应用程序。
|
||||
- **SSH 工具** - 创建可重用的命令片段,单击即可执行。在多个打开的终端上同时运行一个命令。
|
||||
- **命令历史** - 自动完成并查看以前运行的 SSH 命令
|
||||
- **命令面板** - 双击左 Shift 键可快速使用键盘访问 SSH 连接
|
||||
- **SSH 功能丰富** - 支持跳板机、warpgate、基于 TOTP 的连接等。
|
||||
|
||||
# 计划功能
|
||||
|
||||
@@ -75,16 +78,16 @@ Termix 是一个开源、永久免费、自托管的一体化服务器管理平
|
||||
- Windows(x64/ia32)
|
||||
- 便携版
|
||||
- MSI 安装程序
|
||||
- Chocolatey 软件包管理器
|
||||
- Chocolatey 软件包管理器(即将推出)
|
||||
- Linux(x64/ia32)
|
||||
- 便携版
|
||||
- AppImage
|
||||
- Deb
|
||||
- Flatpak
|
||||
- Flatpak(即将推出)
|
||||
- macOS(x64/ia32 on v12.0+)
|
||||
- Apple App Store
|
||||
- Apple App Store(即将推出)
|
||||
- DMG
|
||||
- Homebrew
|
||||
- Homebrew(即将推出)
|
||||
- iOS/iPadOS(v15.1+)
|
||||
- Apple App Store
|
||||
- ISO
|
||||
|
||||
@@ -56,14 +56,17 @@ free and self-hosted alternative to Termius available for all platforms.
|
||||
- **SSH Host Manager** - Save, organize, and manage your SSH connections with tags and folders, and easily save reusable login info while being able to automate the deployment of SSH keys
|
||||
- **Server Stats** - View CPU, memory, and disk usage along with network, uptime, and system information on any SSH server
|
||||
- **Dashboard** - View server information at a glance on your dashboard
|
||||
- **User Authentication** - Secure user management with admin controls and OIDC and 2FA (TOTP) support. View active user sessions across all platforms and revoke permissions.
|
||||
- **Database Encryption** - Backend stored as encrypted SQLite database files
|
||||
- **User Authentication** - Secure user management with admin controls and OIDC and 2FA (TOTP) support. View active user sessions across all platforms and revoke permissions. Link your OIDC/Local accounts together.
|
||||
- **Database Encryption** - Backend stored as encrypted SQLite database files. View [docs](https://docs.termix.site/security) for more.
|
||||
- **Data Export/Import** - Export and import SSH hosts, credentials, and file manager data
|
||||
- **Automatic SSL Setup** - Built-in SSL certificate generation and management with HTTPS redirects
|
||||
- **Modern UI** - Clean desktop/mobile-friendly interface built with React, Tailwind CSS, and Shadcn
|
||||
- **Languages** - Built-in support for English, Chinese, German, and Portuguese
|
||||
- **Platform Support** - Available as a web app, desktop application (Windows, Linux, and macOS), and dedicated mobile/tablet app for iOS and Android.
|
||||
- **SSH Tools** - Create reusable command snippets that execute with a single click. Run one command simultaneously across multiple open terminals.
|
||||
- **Command History** - Auto-complete and view previously ran SSH commands
|
||||
- **Command Palette** - Double tap left shift to quickly access SSH connections with your keyboard
|
||||
- **SSH Feature Rich** - Supports jump hosts, warpgate, TOTP based connections, etc.
|
||||
|
||||
# Planned Features
|
||||
|
||||
|
||||
49
docker/docker-compose.yml
Normal file
49
docker/docker-compose.yml
Normal file
@@ -0,0 +1,49 @@
|
||||
services:
|
||||
termix:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
container_name: termix
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- termix_data:/app/db/data
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=8080
|
||||
- GUACD_HOST=guacd
|
||||
- GUACD_PORT=4822
|
||||
- ENABLE_GUACAMOLE=true
|
||||
depends_on:
|
||||
- guacd
|
||||
networks:
|
||||
- termix-network
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
guacd:
|
||||
image: guacamole/guacd:latest
|
||||
container_name: termix-guacd
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- termix-network
|
||||
healthcheck:
|
||||
test: ["CMD", "nc", "-z", "localhost", "4822"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
networks:
|
||||
termix-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
termix_data:
|
||||
driver: local
|
||||
|
||||
@@ -115,6 +115,15 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/terminal(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/database(/.*)?$ {
|
||||
client_max_body_size 5G;
|
||||
client_body_timeout 300s;
|
||||
@@ -194,6 +203,41 @@ http {
|
||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
||||
}
|
||||
|
||||
# Guacamole WebSocket for RDP/VNC/Telnet
|
||||
# ^~ modifier ensures this takes precedence over the regex location below
|
||||
location ^~ /guacamole/websocket/ {
|
||||
proxy_pass http://127.0.0.1:30007/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
proxy_connect_timeout 10s;
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
|
||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
||||
}
|
||||
|
||||
# Guacamole REST API
|
||||
location ~ ^/guacamole(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ssh/tunnel/ {
|
||||
proxy_pass http://127.0.0.1:30003;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -112,6 +112,15 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/terminal(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/database(/.*)?$ {
|
||||
client_max_body_size 5G;
|
||||
client_body_timeout 300s;
|
||||
@@ -191,6 +200,41 @@ http {
|
||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
||||
}
|
||||
|
||||
# Guacamole WebSocket for RDP/VNC/Telnet
|
||||
# ^~ modifier ensures this takes precedence over the regex location below
|
||||
location ^~ /guacamole/websocket/ {
|
||||
proxy_pass http://127.0.0.1:30007/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
proxy_connect_timeout 10s;
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
|
||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
||||
}
|
||||
|
||||
# Guacamole REST API
|
||||
location ~ ^/guacamole(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ssh/tunnel/ {
|
||||
proxy_pass http://127.0.0.1:30003;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -124,5 +124,6 @@
|
||||
"ITSAppUsesNonExemptEncryption": false,
|
||||
"NSAppleEventsUsageDescription": "Termix needs access to control other applications for terminal operations."
|
||||
}
|
||||
}
|
||||
},
|
||||
"generateUpdatesFilesForAllChannels": true
|
||||
}
|
||||
|
||||
@@ -11,13 +11,11 @@ const fs = require("fs");
|
||||
const os = require("os");
|
||||
|
||||
if (process.platform === "linux") {
|
||||
app.commandLine.appendSwitch("--no-sandbox");
|
||||
app.commandLine.appendSwitch("--disable-setuid-sandbox");
|
||||
app.commandLine.appendSwitch("--disable-dev-shm-usage");
|
||||
// Enable Ozone platform auto-detection for Wayland/X11 support
|
||||
app.commandLine.appendSwitch("--ozone-platform-hint=auto");
|
||||
|
||||
app.disableHardwareAcceleration();
|
||||
app.commandLine.appendSwitch("--disable-gpu");
|
||||
app.commandLine.appendSwitch("--disable-gpu-compositing");
|
||||
// Enable hardware video decoding if available
|
||||
app.commandLine.appendSwitch("--enable-features=VaapiVideoDecoder");
|
||||
}
|
||||
|
||||
app.commandLine.appendSwitch("--ignore-certificate-errors");
|
||||
@@ -395,6 +393,48 @@ ipcMain.handle("save-server-config", (event, config) => {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("get-setting", (event, key) => {
|
||||
try {
|
||||
const userDataPath = app.getPath("userData");
|
||||
const settingsPath = path.join(userDataPath, "settings.json");
|
||||
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const settingsData = fs.readFileSync(settingsPath, "utf8");
|
||||
const settings = JSON.parse(settingsData);
|
||||
return settings[key] !== undefined ? settings[key] : null;
|
||||
} catch (error) {
|
||||
console.error("Error reading setting:", error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("set-setting", (event, key, value) => {
|
||||
try {
|
||||
const userDataPath = app.getPath("userData");
|
||||
const settingsPath = path.join(userDataPath, "settings.json");
|
||||
|
||||
if (!fs.existsSync(userDataPath)) {
|
||||
fs.mkdirSync(userDataPath, { recursive: true });
|
||||
}
|
||||
|
||||
let settings = {};
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
const settingsData = fs.readFileSync(settingsPath, "utf8");
|
||||
settings = JSON.parse(settingsData);
|
||||
}
|
||||
|
||||
settings[key] = value;
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Error saving setting:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("test-server-connection", async (event, serverUrl) => {
|
||||
try {
|
||||
const https = require("https");
|
||||
|
||||
@@ -22,6 +22,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
isElectron: true,
|
||||
isDev: process.env.NODE_ENV === "development",
|
||||
|
||||
getSetting: (key) => ipcRenderer.invoke("get-setting", key),
|
||||
setSetting: (key, value) => ipcRenderer.invoke("set-setting", key, value),
|
||||
|
||||
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[Desktop Entry]
|
||||
Name=Termix
|
||||
Comment=Web-based server management platform with SSH terminal, tunneling, and file editing
|
||||
Exec=termix %U
|
||||
Exec=run.sh %U
|
||||
Icon=com.karmaa.termix
|
||||
Terminal=false
|
||||
Type=Application
|
||||
|
||||
12
flatpak/com.karmaa.termix.flatpakref
Normal file
12
flatpak/com.karmaa.termix.flatpakref
Normal file
@@ -0,0 +1,12 @@
|
||||
[Flatpak Ref]
|
||||
Name=Termix
|
||||
Branch=stable
|
||||
Title=Termix - SSH Server Management Platform
|
||||
IsRuntime=false
|
||||
Url=https://github.com/Termix-SSH/Termix/releases/download/VERSION_PLACEHOLDER/termix_linux_flatpak.flatpak
|
||||
GPGKey=
|
||||
RuntimeRepo=https://flathub.org/repo/flathub.flatpakrepo
|
||||
Comment=Web-based server management platform with SSH terminal, tunneling, and file editing
|
||||
Description=Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides SSH terminal access, tunneling capabilities, and remote file management.
|
||||
Icon=https://raw.githubusercontent.com/Termix-SSH/Termix/main/public/icon.png
|
||||
Homepage=https://github.com/Termix-SSH/Termix
|
||||
@@ -5,7 +5,7 @@
|
||||
<summary>Web-based server management platform with SSH terminal, tunneling, and file editing</summary>
|
||||
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>GPL-3.0-or-later</project_license>
|
||||
<project_license>Apache-2.0</project_license>
|
||||
|
||||
<developer_name>bugattiguy527</developer_name>
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
app-id: com.karmaa.termix
|
||||
runtime: org.freedesktop.Platform
|
||||
runtime-version: "23.08"
|
||||
runtime-version: "24.08"
|
||||
sdk: org.freedesktop.Sdk
|
||||
base: org.electronjs.Electron2.BaseApp
|
||||
base-version: "23.08"
|
||||
command: termix
|
||||
base-version: "24.08"
|
||||
command: run.sh
|
||||
separate-locales: false
|
||||
|
||||
finish-args:
|
||||
@@ -16,8 +16,11 @@ finish-args:
|
||||
- --device=dri
|
||||
- --filesystem=home
|
||||
- --socket=ssh-auth
|
||||
- --talk-name=org.freedesktop.Notifications
|
||||
- --socket=session-bus
|
||||
- --talk-name=org.freedesktop.secrets
|
||||
- --env=ELECTRON_TRASH=gio
|
||||
- --env=XCURSOR_PATH=/run/host/user-share/icons:/run/host/share/icons
|
||||
- --env=ELECTRON_OZONE_PLATFORM_HINT=auto
|
||||
|
||||
modules:
|
||||
- name: termix
|
||||
@@ -30,6 +33,21 @@ modules:
|
||||
- cp -r squashfs-root/resources /app/bin/
|
||||
- cp -r squashfs-root/locales /app/bin/ || true
|
||||
|
||||
- cp squashfs-root/*.so /app/bin/ || true
|
||||
- cp squashfs-root/*.pak /app/bin/ || true
|
||||
- cp squashfs-root/*.bin /app/bin/ || true
|
||||
- cp squashfs-root/*.dat /app/bin/ || true
|
||||
- cp squashfs-root/*.json /app/bin/ || true
|
||||
|
||||
- |
|
||||
cat > run.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
export TMPDIR="$XDG_RUNTIME_DIR/app/$FLATPAK_ID"
|
||||
exec zypak-wrapper /app/bin/termix "$@"
|
||||
EOF
|
||||
- chmod +x run.sh
|
||||
- install -Dm755 run.sh /app/bin/run.sh
|
||||
|
||||
- install -Dm644 com.karmaa.termix.desktop /app/share/applications/com.karmaa.termix.desktop
|
||||
|
||||
- install -Dm644 com.karmaa.termix.metainfo.xml /app/share/metainfo/com.karmaa.termix.metainfo.xml
|
||||
@@ -40,14 +58,14 @@ modules:
|
||||
|
||||
sources:
|
||||
- type: file
|
||||
url: https://github.com/Termix-SSH/Termix/releases/download/release-VERSION_PLACEHOLDER-tag/termix_linux_x64_VERSION_PLACEHOLDER_appimage.AppImage
|
||||
url: https://github.com/Termix-SSH/Termix/releases/download/release-VERSION_PLACEHOLDER-tag/termix_linux_x64_appimage.AppImage
|
||||
sha256: CHECKSUM_X64_PLACEHOLDER
|
||||
dest-filename: termix.AppImage
|
||||
only-arches:
|
||||
- x86_64
|
||||
|
||||
- type: file
|
||||
url: https://github.com/Termix-SSH/Termix/releases/download/release-VERSION_PLACEHOLDER-tag/termix_linux_arm64_VERSION_PLACEHOLDER_appimage.AppImage
|
||||
url: https://github.com/Termix-SSH/Termix/releases/download/release-VERSION_PLACEHOLDER-tag/termix_linux_arm64_appimage.AppImage
|
||||
sha256: CHECKSUM_ARM64_PLACEHOLDER
|
||||
dest-filename: termix.AppImage
|
||||
only-arches:
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
VERSION="$1"
|
||||
CHECKSUM="$2"
|
||||
RELEASE_DATE="$3"
|
||||
|
||||
if [ -z "$VERSION" ] || [ -z "$CHECKSUM" ] || [ -z "$RELEASE_DATE" ]; then
|
||||
echo "Usage: $0 <version> <checksum> <release-date>"
|
||||
echo "Example: $0 1.8.0 abc123... 2025-10-26"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Preparing Flatpak submission for version $VERSION"
|
||||
|
||||
cp public/icon.svg flatpak/com.karmaa.termix.svg
|
||||
echo "✓ Copied SVG icon"
|
||||
|
||||
if command -v convert &> /dev/null; then
|
||||
convert public/icon.png -resize 256x256 flatpak/icon-256.png
|
||||
convert public/icon.png -resize 128x128 flatpak/icon-128.png
|
||||
echo "✓ Generated PNG icons"
|
||||
else
|
||||
cp public/icon.png flatpak/icon-256.png
|
||||
cp public/icon.png flatpak/icon-128.png
|
||||
echo "⚠ ImageMagick not found, using original icon"
|
||||
fi
|
||||
|
||||
sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" flatpak/com.karmaa.termix.yml
|
||||
sed -i "s/CHECKSUM_PLACEHOLDER/$CHECKSUM/g" flatpak/com.karmaa.termix.yml
|
||||
echo "✓ Updated manifest with version $VERSION"
|
||||
|
||||
sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" flatpak/com.karmaa.termix.metainfo.xml
|
||||
sed -i "s/DATE_PLACEHOLDER/$RELEASE_DATE/g" flatpak/com.karmaa.termix.metainfo.xml
|
||||
112
package-lock.json
generated
112
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "termix",
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "termix",
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.0",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.18.7",
|
||||
"@codemirror/commands": "^6.3.3",
|
||||
@@ -33,6 +33,7 @@
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cookie-parser": "^1.4.9",
|
||||
"@types/guacamole-common-js": "^1.5.5",
|
||||
"@types/jszip": "^3.4.0",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
@@ -51,11 +52,14 @@
|
||||
"chalk": "^4.1.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.0",
|
||||
"drizzle-orm": "^0.44.3",
|
||||
"express": "^5.1.0",
|
||||
"guacamole-common-js": "^1.5.0",
|
||||
"guacamole-lite": "^1.2.0",
|
||||
"i18next": "^25.4.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"jose": "^5.2.3",
|
||||
@@ -153,7 +157,6 @@
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -439,7 +442,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.19.1.tgz",
|
||||
"integrity": "sha512-q6NenYkEy2fn9+JyjIxMWcNjzTL/IhwqfzOut1/G3PrIFkrbl4AL7Wkse5tLrQUUyqGoAKU5+Pi5jnnXxH5HGw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
@@ -488,7 +490,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
|
||||
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
@@ -515,7 +516,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
|
||||
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/lang-css": "^6.0.0",
|
||||
@@ -543,7 +543,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz",
|
||||
"integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.6.0",
|
||||
@@ -744,7 +743,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz",
|
||||
"integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
@@ -821,7 +819,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz",
|
||||
"integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
@@ -843,7 +840,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz",
|
||||
"integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.5.0",
|
||||
"crelt": "^1.0.6",
|
||||
@@ -1171,7 +1167,6 @@
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -1558,6 +1553,7 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cross-dirname": "^0.1.0",
|
||||
"debug": "^4.3.4",
|
||||
@@ -1579,6 +1575,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
@@ -2535,8 +2532,7 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.3.0.tgz",
|
||||
"integrity": "sha512-L9X8uHCYU310o99L3/MpJKYxPzXPOS7S0NmBaM7UO/x2Kb2WbmMLSkfvdr1KxRIFYOpbY0Jhn7CfLSUDzL8arQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/cpp": {
|
||||
"version": "1.1.3",
|
||||
@@ -2576,7 +2572,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
|
||||
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.3.0"
|
||||
}
|
||||
@@ -2608,7 +2603,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
|
||||
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.1.3",
|
||||
@@ -2631,7 +2625,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz",
|
||||
"integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
@@ -4855,7 +4848,6 @@
|
||||
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
@@ -5013,7 +5005,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.5.tgz",
|
||||
"integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^5.0.0",
|
||||
@@ -5042,6 +5033,11 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/guacamole-common-js": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/guacamole-common-js/-/guacamole-common-js-1.5.5.tgz",
|
||||
"integrity": "sha512-dqDYo/PhbOXFGSph23rFDRZRzXdKPXy/nsTkovFMb6P3iGrd0qGB5r5BXHmX5Cr/LK7L1TK9nYrTMbtPkhdXyg=="
|
||||
},
|
||||
"node_modules/@types/hast": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
|
||||
@@ -5136,7 +5132,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.2.tgz",
|
||||
"integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
@@ -5179,7 +5174,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -5190,7 +5184,6 @@
|
||||
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -5358,7 +5351,6 @@
|
||||
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.46.2",
|
||||
"@typescript-eslint/types": "8.46.2",
|
||||
@@ -5735,8 +5727,7 @@
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/7zip-bin": {
|
||||
"version": "5.2.0",
|
||||
@@ -5771,7 +5762,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -6194,7 +6184,6 @@
|
||||
"integrity": "sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"bindings": "^1.5.0",
|
||||
"prebuild-install": "^7.1.1"
|
||||
@@ -6329,7 +6318,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.19",
|
||||
"caniuse-lite": "^1.0.30001751",
|
||||
@@ -6936,6 +6924,22 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/cmdk": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
|
||||
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "^1.1.1",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-id": "^1.1.0",
|
||||
"@radix-ui/react-primitive": "^2.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19 || ^19.0.0-rc",
|
||||
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/codem-isoboxer": {
|
||||
"version": "0.3.10",
|
||||
"resolved": "https://registry.npmjs.org/codem-isoboxer/-/codem-isoboxer-0.3.10.tgz",
|
||||
@@ -7317,7 +7321,6 @@
|
||||
"integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"env-paths": "^2.2.1",
|
||||
"import-fresh": "^3.3.0",
|
||||
@@ -7394,7 +7397,8 @@
|
||||
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
@@ -7853,7 +7857,6 @@
|
||||
"integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "26.0.12",
|
||||
"builder-util": "26.0.11",
|
||||
@@ -7951,7 +7954,8 @@
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.7.tgz",
|
||||
"integrity": "sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)"
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dot-prop": {
|
||||
"version": "5.3.0",
|
||||
@@ -8303,6 +8307,7 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@electron/asar": "^3.2.1",
|
||||
"debug": "^4.1.1",
|
||||
@@ -8323,6 +8328,7 @@
|
||||
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.2",
|
||||
"jsonfile": "^4.0.0",
|
||||
@@ -8338,6 +8344,7 @@
|
||||
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"optionalDependencies": {
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
@@ -8348,6 +8355,7 @@
|
||||
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
@@ -8610,7 +8618,6 @@
|
||||
"integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -9813,6 +9820,23 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/guacamole-common-js": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/guacamole-common-js/-/guacamole-common-js-1.5.0.tgz",
|
||||
"integrity": "sha512-zxztif3GGhKbg1RgOqwmqot8kXgv2HmHFg1EvWwd4q7UfEKvBcYZ0f+7G8HzvU+FUxF0Psqm9Kl5vCbgfrRgJg=="
|
||||
},
|
||||
"node_modules/guacamole-lite": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/guacamole-lite/-/guacamole-lite-1.2.0.tgz",
|
||||
"integrity": "sha512-NeSYgbT5s5rxF0SE/kzJsV5Gg0IvnqoTOCbNIUMl23z1+SshaVfLExpxrEXSGTG0cdvY5lfZC1fOAepYriaXGg==",
|
||||
"dependencies": {
|
||||
"deep-extend": "^0.6.0",
|
||||
"ws": "^8.15.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
@@ -10209,7 +10233,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.27.6"
|
||||
},
|
||||
@@ -10735,9 +10758,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -11751,6 +11774,7 @@
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
|
||||
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
@@ -13778,6 +13802,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"commander": "^9.4.0"
|
||||
},
|
||||
@@ -13795,6 +13820,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
@@ -14246,7 +14272,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -14256,7 +14281,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -14283,7 +14307,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
|
||||
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@@ -14431,7 +14454,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
@@ -14640,8 +14662,7 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
@@ -15962,6 +15983,7 @@
|
||||
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mkdirp": "^0.5.1",
|
||||
"rimraf": "~2.6.2"
|
||||
@@ -16002,6 +16024,7 @@
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
@@ -16016,6 +16039,7 @@
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
@@ -16120,7 +16144,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -16326,7 +16349,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -16739,7 +16761,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz",
|
||||
"integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -16831,7 +16852,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "termix",
|
||||
"private": true,
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.0",
|
||||
"description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities",
|
||||
"author": "Karmaa",
|
||||
"main": "electron/main.cjs",
|
||||
@@ -52,6 +52,7 @@
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cookie-parser": "^1.4.9",
|
||||
"@types/guacamole-common-js": "^1.5.5",
|
||||
"@types/jszip": "^3.4.0",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
@@ -70,11 +71,14 @@
|
||||
"chalk": "^4.1.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.0",
|
||||
"drizzle-orm": "^0.44.3",
|
||||
"express": "^5.1.0",
|
||||
"guacamole-common-js": "^1.5.0",
|
||||
"guacamole-lite": "^1.2.0",
|
||||
"i18next": "^25.4.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"jose": "^5.2.3",
|
||||
|
||||
@@ -7,6 +7,8 @@ import sshRoutes from "./routes/ssh.js";
|
||||
import alertRoutes from "./routes/alerts.js";
|
||||
import credentialsRoutes from "./routes/credentials.js";
|
||||
import snippetsRoutes from "./routes/snippets.js";
|
||||
import terminalRoutes from "./routes/terminal.js";
|
||||
import guacamoleRoutes from "../guacamole/routes.js";
|
||||
import cors from "cors";
|
||||
import fetch from "node-fetch";
|
||||
import fs from "fs";
|
||||
@@ -21,6 +23,7 @@ import { DatabaseMigration } from "../utils/database-migration.js";
|
||||
import { UserDataExport } from "../utils/user-data-export.js";
|
||||
import { AutoSSLSetup } from "../utils/auto-ssl-setup.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { parseUserAgent } from "../utils/user-agent-parser.js";
|
||||
import {
|
||||
users,
|
||||
sshData,
|
||||
@@ -456,8 +459,12 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
code: "PASSWORD_REQUIRED",
|
||||
});
|
||||
}
|
||||
|
||||
const unlocked = await authManager.authenticateUser(userId, password);
|
||||
const deviceInfo = parseUserAgent(req);
|
||||
const unlocked = await authManager.authenticateUser(
|
||||
userId,
|
||||
password,
|
||||
deviceInfo.type,
|
||||
);
|
||||
if (!unlocked) {
|
||||
return res.status(401).json({ error: "Invalid password" });
|
||||
}
|
||||
@@ -904,6 +911,7 @@ app.post(
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { password } = req.body;
|
||||
const mainDb = getDb();
|
||||
const deviceInfo = parseUserAgent(req);
|
||||
|
||||
const userRecords = await mainDb
|
||||
.select()
|
||||
@@ -924,12 +932,19 @@ app.post(
|
||||
});
|
||||
}
|
||||
|
||||
const unlocked = await authManager.authenticateUser(userId, password);
|
||||
const unlocked = await authManager.authenticateUser(
|
||||
userId,
|
||||
password,
|
||||
deviceInfo.type,
|
||||
);
|
||||
if (!unlocked) {
|
||||
return res.status(401).json({ error: "Invalid password" });
|
||||
}
|
||||
} else if (!DataCrypto.getUserDataKey(userId)) {
|
||||
const oidcUnlocked = await authManager.authenticateOIDCUser(userId);
|
||||
const oidcUnlocked = await authManager.authenticateOIDCUser(
|
||||
userId,
|
||||
deviceInfo.type,
|
||||
);
|
||||
if (!oidcUnlocked) {
|
||||
return res.status(403).json({
|
||||
error: "Failed to unlock user data with SSO credentials",
|
||||
@@ -947,7 +962,10 @@ app.post(
|
||||
|
||||
let userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDataKey && isOidcUser) {
|
||||
const oidcUnlocked = await authManager.authenticateOIDCUser(userId);
|
||||
const oidcUnlocked = await authManager.authenticateOIDCUser(
|
||||
userId,
|
||||
deviceInfo.type,
|
||||
);
|
||||
if (oidcUnlocked) {
|
||||
userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
}
|
||||
@@ -1418,6 +1436,8 @@ app.use("/ssh", sshRoutes);
|
||||
app.use("/alerts", alertRoutes);
|
||||
app.use("/credentials", credentialsRoutes);
|
||||
app.use("/snippets", snippetsRoutes);
|
||||
app.use("/terminal", terminalRoutes);
|
||||
app.use("/guacamole", guacamoleRoutes);
|
||||
|
||||
app.use(
|
||||
(
|
||||
@@ -1480,13 +1500,13 @@ app.get(
|
||||
if (status.hasUnencryptedDb) {
|
||||
try {
|
||||
unencryptedSize = fs.statSync(dbPath).size;
|
||||
} catch {}
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
if (status.hasEncryptedDb) {
|
||||
try {
|
||||
encryptedSize = fs.statSync(encryptedDbPath).size;
|
||||
} catch {}
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
res.json({
|
||||
|
||||
@@ -95,6 +95,26 @@ async function initializeDatabaseAsync(): Promise<void> {
|
||||
databaseKeyLength: process.env.DATABASE_KEY?.length || 0,
|
||||
});
|
||||
|
||||
try {
|
||||
const diagnosticInfo =
|
||||
DatabaseFileEncryption.getDiagnosticInfo(encryptedDbPath);
|
||||
databaseLogger.error(
|
||||
"Database encryption diagnostic completed - check logs above for details",
|
||||
null,
|
||||
{
|
||||
operation: "db_encryption_diagnostic_completed",
|
||||
filesConsistent: diagnosticInfo.validation.filesConsistent,
|
||||
sizeMismatch: diagnosticInfo.validation.sizeMismatch,
|
||||
},
|
||||
);
|
||||
} catch (diagError) {
|
||||
databaseLogger.warn("Failed to generate diagnostic information", {
|
||||
operation: "db_diagnostic_failed",
|
||||
error:
|
||||
diagError instanceof Error ? diagError.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Database decryption failed: ${error instanceof Error ? error.message : "Unknown error"}. This prevents data loss.`,
|
||||
);
|
||||
@@ -120,6 +140,8 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
|
||||
sqlite = memoryDatabase;
|
||||
|
||||
sqlite.exec("PRAGMA foreign_keys = ON");
|
||||
|
||||
db = drizzle(sqlite, { schema });
|
||||
|
||||
sqlite.exec(`
|
||||
@@ -157,7 +179,7 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TEXT NOT NULL,
|
||||
last_active_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_data (
|
||||
@@ -179,16 +201,18 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
||||
tunnel_connections TEXT,
|
||||
enable_file_manager INTEGER NOT NULL DEFAULT 1,
|
||||
enable_docker INTEGER NOT NULL DEFAULT 0,
|
||||
default_path TEXT,
|
||||
autostart_password TEXT,
|
||||
autostart_key TEXT,
|
||||
autostart_key_password TEXT,
|
||||
force_keyboard_interactive TEXT,
|
||||
stats_config TEXT,
|
||||
docker_config TEXT,
|
||||
terminal_config TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS file_manager_recent (
|
||||
@@ -198,8 +222,8 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id),
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS file_manager_pinned (
|
||||
@@ -209,8 +233,8 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id),
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS file_manager_shortcuts (
|
||||
@@ -220,8 +244,8 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id),
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dismissed_alerts (
|
||||
@@ -229,7 +253,7 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
user_id TEXT NOT NULL,
|
||||
alert_id TEXT NOT NULL,
|
||||
dismissed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_credentials (
|
||||
@@ -249,7 +273,7 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
last_used TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_credential_usage (
|
||||
@@ -258,9 +282,9 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
host_id INTEGER NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id),
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id),
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS snippets (
|
||||
@@ -271,7 +295,18 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
description TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_folders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT,
|
||||
icon TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recent_activity (
|
||||
@@ -279,10 +314,20 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
user_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
host_name TEXT NOT NULL,
|
||||
host_name TEXT,
|
||||
timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id),
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS command_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
command TEXT NOT NULL,
|
||||
executed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
`);
|
||||
@@ -343,14 +388,14 @@ const addColumnIfNotExists = (
|
||||
try {
|
||||
sqlite
|
||||
.prepare(
|
||||
`SELECT ${column}
|
||||
`SELECT "${column}"
|
||||
FROM ${table} LIMIT 1`,
|
||||
)
|
||||
.get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`ALTER TABLE ${table}
|
||||
ADD COLUMN ${column} ${definition};`);
|
||||
ADD COLUMN "${column}" ${definition};`);
|
||||
} catch (alterError) {
|
||||
databaseLogger.warn(`Failed to add column ${column} to ${table}`, {
|
||||
operation: "schema_migration",
|
||||
@@ -405,6 +450,7 @@ const migrateSchema = () => {
|
||||
"INTEGER NOT NULL DEFAULT 1",
|
||||
);
|
||||
addColumnIfNotExists("ssh_data", "tunnel_connections", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "jump_hosts", "TEXT");
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"enable_file_manager",
|
||||
@@ -428,7 +474,12 @@ const migrateSchema = () => {
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"credential_id",
|
||||
"INTEGER REFERENCES ssh_credentials(id)",
|
||||
"INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL",
|
||||
);
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"override_credential_username",
|
||||
"INTEGER",
|
||||
);
|
||||
|
||||
addColumnIfNotExists("ssh_data", "autostart_password", "TEXT");
|
||||
@@ -436,6 +487,20 @@ const migrateSchema = () => {
|
||||
addColumnIfNotExists("ssh_data", "autostart_key_password", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "stats_config", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "terminal_config", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "quick_actions", "TEXT");
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"enable_docker",
|
||||
"INTEGER NOT NULL DEFAULT 0",
|
||||
);
|
||||
addColumnIfNotExists("ssh_data", "docker_config", "TEXT");
|
||||
|
||||
// Connection type columns for RDP/VNC/Telnet support
|
||||
addColumnIfNotExists("ssh_data", "connection_type", 'TEXT NOT NULL DEFAULT "ssh"');
|
||||
addColumnIfNotExists("ssh_data", "domain", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "security", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "ignore_cert", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumnIfNotExists("ssh_data", "guacamole_config", "TEXT");
|
||||
|
||||
addColumnIfNotExists("ssh_credentials", "private_key", "TEXT");
|
||||
addColumnIfNotExists("ssh_credentials", "public_key", "TEXT");
|
||||
@@ -445,6 +510,35 @@ const migrateSchema = () => {
|
||||
addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL");
|
||||
addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL");
|
||||
|
||||
addColumnIfNotExists("snippets", "folder", "TEXT");
|
||||
addColumnIfNotExists("snippets", "order", "INTEGER NOT NULL DEFAULT 0");
|
||||
|
||||
try {
|
||||
sqlite
|
||||
.prepare("SELECT id FROM snippet_folders LIMIT 1")
|
||||
.get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS snippet_folders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT,
|
||||
icon TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create snippet_folders table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite
|
||||
.prepare("SELECT id FROM sessions LIMIT 1")
|
||||
|
||||
@@ -34,7 +34,7 @@ export const sessions = sqliteTable("sessions", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
jwtToken: text("jwt_token").notNull(),
|
||||
deviceType: text("device_type").notNull(),
|
||||
deviceInfo: text("device_info").notNull(),
|
||||
@@ -51,7 +51,9 @@ export const sshData = sqliteTable("ssh_data", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
// Connection type: ssh, rdp, vnc, telnet
|
||||
connectionType: text("connection_type").notNull().default("ssh"),
|
||||
name: text("name"),
|
||||
ip: text("ip").notNull(),
|
||||
port: integer("port").notNull(),
|
||||
@@ -71,7 +73,10 @@ export const sshData = sqliteTable("ssh_data", {
|
||||
autostartKey: text("autostart_key", { length: 8192 }),
|
||||
autostartKeyPassword: text("autostart_key_password"),
|
||||
|
||||
credentialId: integer("credential_id").references(() => sshCredentials.id),
|
||||
credentialId: integer("credential_id").references(() => sshCredentials.id, { onDelete: "set null" }),
|
||||
overrideCredentialUsername: integer("override_credential_username", {
|
||||
mode: "boolean",
|
||||
}),
|
||||
enableTerminal: integer("enable_terminal", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
@@ -79,12 +84,24 @@ export const sshData = sqliteTable("ssh_data", {
|
||||
.notNull()
|
||||
.default(true),
|
||||
tunnelConnections: text("tunnel_connections"),
|
||||
jumpHosts: text("jump_hosts"),
|
||||
enableFileManager: integer("enable_file_manager", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
enableDocker: integer("enable_docker", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
defaultPath: text("default_path"),
|
||||
statsConfig: text("stats_config"),
|
||||
dockerConfig: text("docker_config"),
|
||||
terminalConfig: text("terminal_config"),
|
||||
quickActions: text("quick_actions"),
|
||||
// RDP/VNC specific fields
|
||||
domain: text("domain"),
|
||||
security: text("security"),
|
||||
ignoreCert: integer("ignore_cert", { mode: "boolean" }).default(false),
|
||||
// RDP/VNC extended configuration (stored as JSON)
|
||||
guacamoleConfig: text("guacamole_config"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
@@ -97,10 +114,10 @@ export const fileManagerRecent = sqliteTable("file_manager_recent", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id),
|
||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
path: text("path").notNull(),
|
||||
lastOpened: text("last_opened")
|
||||
@@ -112,10 +129,10 @@ export const fileManagerPinned = sqliteTable("file_manager_pinned", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id),
|
||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
path: text("path").notNull(),
|
||||
pinnedAt: text("pinned_at")
|
||||
@@ -127,10 +144,10 @@ export const fileManagerShortcuts = sqliteTable("file_manager_shortcuts", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id),
|
||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
path: text("path").notNull(),
|
||||
createdAt: text("created_at")
|
||||
@@ -142,7 +159,7 @@ export const dismissedAlerts = sqliteTable("dismissed_alerts", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
alertId: text("alert_id").notNull(),
|
||||
dismissedAt: text("dismissed_at")
|
||||
.notNull()
|
||||
@@ -153,7 +170,7 @@ export const sshCredentials = sqliteTable("ssh_credentials", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
folder: text("folder"),
|
||||
@@ -181,13 +198,13 @@ export const sshCredentialUsage = sqliteTable("ssh_credential_usage", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
credentialId: integer("credential_id")
|
||||
.notNull()
|
||||
.references(() => sshCredentials.id),
|
||||
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id),
|
||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
usedAt: text("used_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
@@ -197,10 +214,44 @@ export const snippets = sqliteTable("snippets", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
content: text("content").notNull(),
|
||||
description: text("description"),
|
||||
folder: text("folder"),
|
||||
order: integer("order").notNull().default(0),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const snippetFolders = sqliteTable("snippet_folders", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
color: text("color"),
|
||||
icon: text("icon"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const sshFolders = sqliteTable("ssh_folders", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
color: text("color"),
|
||||
icon: text("icon"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
@@ -213,13 +264,27 @@ export const recentActivity = sqliteTable("recent_activity", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
type: text("type").notNull(),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id),
|
||||
hostName: text("host_name").notNull(),
|
||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
||||
hostName: text("host_name"),
|
||||
timestamp: text("timestamp")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const commandHistory = sqliteTable("command_history", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
||||
command: text("command").notNull(),
|
||||
executedAt: text("executed_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type {
|
||||
AuthenticatedRequest,
|
||||
CredentialBackend,
|
||||
} from "../../../types/index.js";
|
||||
import express from "express";
|
||||
import { db } from "../db/index.js";
|
||||
import { sshCredentials, sshCredentialUsage, sshData } from "../db/schema.js";
|
||||
@@ -524,6 +527,8 @@ router.delete(
|
||||
return res.status(404).json({ error: "Credential not found" });
|
||||
}
|
||||
|
||||
// Update hosts using this credential to set credentialId to null
|
||||
// This prevents orphaned references before deletion
|
||||
const hostsUsingCredential = await db
|
||||
.select()
|
||||
.from(sshData)
|
||||
@@ -552,14 +557,8 @@ router.delete(
|
||||
);
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(sshCredentialUsage)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentialUsage.credentialId, parseInt(id)),
|
||||
eq(sshCredentialUsage.userId, userId),
|
||||
),
|
||||
);
|
||||
// sshCredentialUsage will be automatically deleted by ON DELETE CASCADE
|
||||
// No need for manual deletion
|
||||
|
||||
await db
|
||||
.delete(sshCredentials)
|
||||
@@ -1128,10 +1127,9 @@ router.post(
|
||||
|
||||
async function deploySSHKeyToHost(
|
||||
hostConfig: Record<string, unknown>,
|
||||
publicKey: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_credentialData: Record<string, unknown>,
|
||||
credData: CredentialBackend,
|
||||
): Promise<{ success: boolean; message?: string; error?: string }> {
|
||||
const publicKey = credData.public_key as string;
|
||||
return new Promise((resolve) => {
|
||||
const conn = new Client();
|
||||
|
||||
@@ -1252,13 +1250,17 @@ async function deploySSHKeyToHost(
|
||||
.replace(/'/g, "'\\''");
|
||||
|
||||
conn.exec(
|
||||
`printf '%s\\n' '${escapedKey}' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`,
|
||||
`printf '%s\\n' '${escapedKey} ${credData.name}@Termix' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`,
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(addTimeout);
|
||||
return rejectAdd(err);
|
||||
}
|
||||
|
||||
stream.on("data", () => {
|
||||
// Consume output
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
clearTimeout(addTimeout);
|
||||
if (code === 0) {
|
||||
@@ -1510,7 +1512,7 @@ router.post(
|
||||
});
|
||||
}
|
||||
|
||||
const credData = credential[0];
|
||||
const credData = credential[0] as unknown as CredentialBackend;
|
||||
|
||||
if (credData.authType !== "key") {
|
||||
return res.status(400).json({
|
||||
@@ -1519,7 +1521,8 @@ router.post(
|
||||
});
|
||||
}
|
||||
|
||||
if (!credData.publicKey) {
|
||||
const publicKey = credData.public_key;
|
||||
if (!publicKey) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "Public key is required for deployment",
|
||||
@@ -1600,7 +1603,6 @@ router.post(
|
||||
|
||||
const deployResult = await deploySSHKeyToHost(
|
||||
hostConfig,
|
||||
credData.publicKey as string,
|
||||
credData,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import express from "express";
|
||||
import { db } from "../db/index.js";
|
||||
import { snippets } from "../db/schema.js";
|
||||
import { eq, and, desc, sql } from "drizzle-orm";
|
||||
import { snippets, snippetFolders } from "../db/schema.js";
|
||||
import { eq, and, desc, asc, sql } from "drizzle-orm";
|
||||
import type { Request, Response } from "express";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
@@ -17,6 +17,651 @@ const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||
|
||||
// Get all snippet folders
|
||||
// GET /snippets/folders
|
||||
router.get(
|
||||
"/folders",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!isNonEmptyString(userId)) {
|
||||
authLogger.warn("Invalid userId for snippet folders fetch");
|
||||
return res.status(400).json({ error: "Invalid userId" });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await db
|
||||
.select()
|
||||
.from(snippetFolders)
|
||||
.where(eq(snippetFolders.userId, userId))
|
||||
.orderBy(asc(snippetFolders.name));
|
||||
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to fetch snippet folders", err);
|
||||
res.status(500).json({ error: "Failed to fetch snippet folders" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Create a new snippet folder
|
||||
// POST /snippets/folders
|
||||
router.post(
|
||||
"/folders",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { name, color, icon } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !isNonEmptyString(name)) {
|
||||
authLogger.warn("Invalid snippet folder creation data", {
|
||||
operation: "snippet_folder_create",
|
||||
userId,
|
||||
hasName: !!name,
|
||||
});
|
||||
return res.status(400).json({ error: "Folder name is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(snippetFolders)
|
||||
.where(
|
||||
and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)),
|
||||
);
|
||||
|
||||
if (existing.length > 0) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ error: "Folder with this name already exists" });
|
||||
}
|
||||
|
||||
const insertData = {
|
||||
userId,
|
||||
name: name.trim(),
|
||||
color: color?.trim() || null,
|
||||
icon: icon?.trim() || null,
|
||||
};
|
||||
|
||||
const result = await db
|
||||
.insert(snippetFolders)
|
||||
.values(insertData)
|
||||
.returning();
|
||||
|
||||
authLogger.success(`Snippet folder created: ${name} by user ${userId}`, {
|
||||
operation: "snippet_folder_create_success",
|
||||
userId,
|
||||
name,
|
||||
});
|
||||
|
||||
res.status(201).json(result[0]);
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to create snippet folder", err);
|
||||
res.status(500).json({
|
||||
error:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to create snippet folder",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Update snippet folder metadata (color, icon)
|
||||
// PUT /snippets/folders/:name/metadata
|
||||
router.put(
|
||||
"/folders/:name/metadata",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { name } = req.params;
|
||||
const { color, icon } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !name) {
|
||||
authLogger.warn("Invalid request for snippet folder metadata update");
|
||||
return res.status(400).json({ error: "Invalid request" });
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(snippetFolders)
|
||||
.where(
|
||||
and(
|
||||
eq(snippetFolders.userId, userId),
|
||||
eq(snippetFolders.name, decodeURIComponent(name)),
|
||||
),
|
||||
);
|
||||
|
||||
if (existing.length === 0) {
|
||||
return res.status(404).json({ error: "Folder not found" });
|
||||
}
|
||||
|
||||
const updateFields: Partial<{
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
updatedAt: ReturnType<typeof sql.raw>;
|
||||
}> = {
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
};
|
||||
|
||||
if (color !== undefined) updateFields.color = color?.trim() || null;
|
||||
if (icon !== undefined) updateFields.icon = icon?.trim() || null;
|
||||
|
||||
await db
|
||||
.update(snippetFolders)
|
||||
.set(updateFields)
|
||||
.where(
|
||||
and(
|
||||
eq(snippetFolders.userId, userId),
|
||||
eq(snippetFolders.name, decodeURIComponent(name)),
|
||||
),
|
||||
);
|
||||
|
||||
const updated = await db
|
||||
.select()
|
||||
.from(snippetFolders)
|
||||
.where(
|
||||
and(
|
||||
eq(snippetFolders.userId, userId),
|
||||
eq(snippetFolders.name, decodeURIComponent(name)),
|
||||
),
|
||||
);
|
||||
|
||||
authLogger.success(
|
||||
`Snippet folder metadata updated: ${name} by user ${userId}`,
|
||||
{
|
||||
operation: "snippet_folder_metadata_update_success",
|
||||
userId,
|
||||
name,
|
||||
},
|
||||
);
|
||||
|
||||
res.json(updated[0]);
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to update snippet folder metadata", err);
|
||||
res.status(500).json({
|
||||
error:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to update snippet folder metadata",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Rename snippet folder
|
||||
// PUT /snippets/folders/rename
|
||||
router.put(
|
||||
"/folders/rename",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { oldName, newName } = req.body;
|
||||
|
||||
if (
|
||||
!isNonEmptyString(userId) ||
|
||||
!isNonEmptyString(oldName) ||
|
||||
!isNonEmptyString(newName)
|
||||
) {
|
||||
authLogger.warn("Invalid request for snippet folder rename");
|
||||
return res.status(400).json({ error: "Invalid request" });
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(snippetFolders)
|
||||
.where(
|
||||
and(
|
||||
eq(snippetFolders.userId, userId),
|
||||
eq(snippetFolders.name, oldName),
|
||||
),
|
||||
);
|
||||
|
||||
if (existing.length === 0) {
|
||||
return res.status(404).json({ error: "Folder not found" });
|
||||
}
|
||||
|
||||
const nameExists = await db
|
||||
.select()
|
||||
.from(snippetFolders)
|
||||
.where(
|
||||
and(
|
||||
eq(snippetFolders.userId, userId),
|
||||
eq(snippetFolders.name, newName),
|
||||
),
|
||||
);
|
||||
|
||||
if (nameExists.length > 0) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ error: "Folder with new name already exists" });
|
||||
}
|
||||
|
||||
await db
|
||||
.update(snippetFolders)
|
||||
.set({ name: newName, updatedAt: sql`CURRENT_TIMESTAMP` })
|
||||
.where(
|
||||
and(
|
||||
eq(snippetFolders.userId, userId),
|
||||
eq(snippetFolders.name, oldName),
|
||||
),
|
||||
);
|
||||
|
||||
await db
|
||||
.update(snippets)
|
||||
.set({ folder: newName })
|
||||
.where(and(eq(snippets.userId, userId), eq(snippets.folder, oldName)));
|
||||
|
||||
authLogger.success(
|
||||
`Snippet folder renamed: ${oldName} -> ${newName} by user ${userId}`,
|
||||
{
|
||||
operation: "snippet_folder_rename_success",
|
||||
userId,
|
||||
oldName,
|
||||
newName,
|
||||
},
|
||||
);
|
||||
|
||||
res.json({ success: true, oldName, newName });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to rename snippet folder", err);
|
||||
res.status(500).json({
|
||||
error:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to rename snippet folder",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Delete snippet folder
|
||||
// DELETE /snippets/folders/:name
|
||||
router.delete(
|
||||
"/folders/:name",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { name } = req.params;
|
||||
|
||||
if (!isNonEmptyString(userId) || !name) {
|
||||
authLogger.warn("Invalid request for snippet folder delete");
|
||||
return res.status(400).json({ error: "Invalid request" });
|
||||
}
|
||||
|
||||
try {
|
||||
const folderName = decodeURIComponent(name);
|
||||
|
||||
await db
|
||||
.update(snippets)
|
||||
.set({ folder: null })
|
||||
.where(
|
||||
and(eq(snippets.userId, userId), eq(snippets.folder, folderName)),
|
||||
);
|
||||
|
||||
await db
|
||||
.delete(snippetFolders)
|
||||
.where(
|
||||
and(
|
||||
eq(snippetFolders.userId, userId),
|
||||
eq(snippetFolders.name, folderName),
|
||||
),
|
||||
);
|
||||
|
||||
authLogger.success(
|
||||
`Snippet folder deleted: ${folderName} by user ${userId}`,
|
||||
{
|
||||
operation: "snippet_folder_delete_success",
|
||||
userId,
|
||||
name: folderName,
|
||||
},
|
||||
);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to delete snippet folder", err);
|
||||
res.status(500).json({
|
||||
error:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to delete snippet folder",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Reorder snippets (bulk update)
|
||||
// PUT /snippets/reorder
|
||||
router.put(
|
||||
"/reorder",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { snippets: snippetUpdates } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId)) {
|
||||
authLogger.warn("Invalid userId for snippet reorder");
|
||||
return res.status(400).json({ error: "Invalid userId" });
|
||||
}
|
||||
|
||||
if (!Array.isArray(snippetUpdates) || snippetUpdates.length === 0) {
|
||||
authLogger.warn("Invalid snippet reorder data", {
|
||||
operation: "snippet_reorder",
|
||||
userId,
|
||||
});
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "snippets array is required and must not be empty" });
|
||||
}
|
||||
|
||||
try {
|
||||
for (const update of snippetUpdates) {
|
||||
const { id, order, folder } = update;
|
||||
|
||||
if (!id || order === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const updateFields: Partial<{
|
||||
order: number;
|
||||
folder: string | null;
|
||||
}> = {
|
||||
order,
|
||||
};
|
||||
|
||||
if (folder !== undefined) {
|
||||
updateFields.folder = folder?.trim() || null;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(snippets)
|
||||
.set(updateFields)
|
||||
.where(and(eq(snippets.id, id), eq(snippets.userId, userId)));
|
||||
}
|
||||
|
||||
authLogger.success(`Snippets reordered by user ${userId}`, {
|
||||
operation: "snippet_reorder_success",
|
||||
userId,
|
||||
count: snippetUpdates.length,
|
||||
});
|
||||
|
||||
res.json({ success: true, updated: snippetUpdates.length });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to reorder snippets", err);
|
||||
res.status(500).json({
|
||||
error:
|
||||
err instanceof Error ? err.message : "Failed to reorder snippets",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Execute a snippet on a host
|
||||
// POST /snippets/execute
|
||||
router.post(
|
||||
"/execute",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { snippetId, hostId } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !snippetId || !hostId) {
|
||||
authLogger.warn("Invalid snippet execution request", {
|
||||
userId,
|
||||
snippetId,
|
||||
hostId,
|
||||
});
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Snippet ID and Host ID are required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const snippetResult = await db
|
||||
.select()
|
||||
.from(snippets)
|
||||
.where(
|
||||
and(
|
||||
eq(snippets.id, parseInt(snippetId)),
|
||||
eq(snippets.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
if (snippetResult.length === 0) {
|
||||
return res.status(404).json({ error: "Snippet not found" });
|
||||
}
|
||||
|
||||
const snippet = snippetResult[0];
|
||||
|
||||
const { Client } = await import("ssh2");
|
||||
const { sshData, sshCredentials } = await import("../db/schema.js");
|
||||
|
||||
const { SimpleDBOps } = await import("../../utils/simple-db-ops.js");
|
||||
|
||||
const hostResult = await SimpleDBOps.select(
|
||||
db
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(
|
||||
and(eq(sshData.id, parseInt(hostId)), eq(sshData.userId, userId)),
|
||||
),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (hostResult.length === 0) {
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
}
|
||||
|
||||
const host = hostResult[0];
|
||||
|
||||
let password = host.password;
|
||||
let privateKey = host.key;
|
||||
let passphrase = host.key_password;
|
||||
let authType = host.authType;
|
||||
|
||||
if (host.credentialId) {
|
||||
const credResult = await SimpleDBOps.select(
|
||||
db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, host.credentialId as number),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (credResult.length > 0) {
|
||||
const cred = credResult[0];
|
||||
authType = (cred.auth_type || cred.authType || authType) as string;
|
||||
password = (cred.password || undefined) as string | undefined;
|
||||
privateKey = (cred.private_key || cred.key || undefined) as
|
||||
| string
|
||||
| undefined;
|
||||
passphrase = (cred.key_password || undefined) as string | undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const conn = new Client();
|
||||
let output = "";
|
||||
let errorOutput = "";
|
||||
|
||||
const executePromise = new Promise<{
|
||||
success: boolean;
|
||||
output: string;
|
||||
error?: string;
|
||||
}>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
conn.end();
|
||||
reject(new Error("Command execution timeout (30s)"));
|
||||
}, 30000);
|
||||
|
||||
conn.on("ready", () => {
|
||||
conn.exec(snippet.content, (err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(timeout);
|
||||
conn.end();
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
stream.on("close", () => {
|
||||
clearTimeout(timeout);
|
||||
conn.end();
|
||||
if (errorOutput) {
|
||||
resolve({ success: false, output, error: errorOutput });
|
||||
} else {
|
||||
resolve({ success: true, output });
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("data", (data: Buffer) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (data: Buffer) => {
|
||||
errorOutput += data.toString();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
conn.on("error", (err) => {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
const config: any = {
|
||||
host: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
tryKeyboard: true,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
readyTimeout: 30000,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
timeout: 30000,
|
||||
env: {
|
||||
TERM: "xterm-256color",
|
||||
LANG: "en_US.UTF-8",
|
||||
LC_ALL: "en_US.UTF-8",
|
||||
LC_CTYPE: "en_US.UTF-8",
|
||||
LC_MESSAGES: "en_US.UTF-8",
|
||||
LC_MONETARY: "en_US.UTF-8",
|
||||
LC_NUMERIC: "en_US.UTF-8",
|
||||
LC_TIME: "en_US.UTF-8",
|
||||
LC_COLLATE: "en_US.UTF-8",
|
||||
COLORTERM: "truecolor",
|
||||
},
|
||||
algorithms: {
|
||||
kex: [
|
||||
"curve25519-sha256",
|
||||
"curve25519-sha256@libssh.org",
|
||||
"ecdh-sha2-nistp521",
|
||||
"ecdh-sha2-nistp384",
|
||||
"ecdh-sha2-nistp256",
|
||||
"diffie-hellman-group-exchange-sha256",
|
||||
"diffie-hellman-group14-sha256",
|
||||
"diffie-hellman-group14-sha1",
|
||||
"diffie-hellman-group-exchange-sha1",
|
||||
"diffie-hellman-group1-sha1",
|
||||
],
|
||||
serverHostKey: [
|
||||
"ssh-ed25519",
|
||||
"ecdsa-sha2-nistp521",
|
||||
"ecdsa-sha2-nistp384",
|
||||
"ecdsa-sha2-nistp256",
|
||||
"rsa-sha2-512",
|
||||
"rsa-sha2-256",
|
||||
"ssh-rsa",
|
||||
"ssh-dss",
|
||||
],
|
||||
cipher: [
|
||||
"chacha20-poly1305@openssh.com",
|
||||
"aes256-gcm@openssh.com",
|
||||
"aes128-gcm@openssh.com",
|
||||
"aes256-ctr",
|
||||
"aes192-ctr",
|
||||
"aes128-ctr",
|
||||
"aes256-cbc",
|
||||
"aes192-cbc",
|
||||
"aes128-cbc",
|
||||
"3des-cbc",
|
||||
],
|
||||
hmac: [
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
"hmac-sha2-512",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha1",
|
||||
"hmac-md5",
|
||||
],
|
||||
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||
},
|
||||
};
|
||||
|
||||
if (authType === "password" && password) {
|
||||
config.password = password;
|
||||
} else if (authType === "key" && privateKey) {
|
||||
const cleanKey = (privateKey as string)
|
||||
.trim()
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n");
|
||||
config.privateKey = Buffer.from(cleanKey, "utf8");
|
||||
if (passphrase) {
|
||||
config.passphrase = passphrase;
|
||||
}
|
||||
} else if (password) {
|
||||
config.password = password;
|
||||
} else if (privateKey) {
|
||||
const cleanKey = (privateKey as string)
|
||||
.trim()
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n");
|
||||
config.privateKey = Buffer.from(cleanKey, "utf8");
|
||||
if (passphrase) {
|
||||
config.passphrase = passphrase;
|
||||
}
|
||||
}
|
||||
|
||||
conn.connect(config);
|
||||
});
|
||||
|
||||
const result = await executePromise;
|
||||
|
||||
authLogger.success(
|
||||
`Snippet executed: ${snippet.name} on host ${hostId}`,
|
||||
{
|
||||
operation: "snippet_execute_success",
|
||||
userId,
|
||||
snippetId,
|
||||
hostId,
|
||||
},
|
||||
);
|
||||
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to execute snippet", err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : "Failed to execute snippet",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Get all snippets for the authenticated user
|
||||
// GET /snippets
|
||||
router.get(
|
||||
@@ -36,7 +681,12 @@ router.get(
|
||||
.select()
|
||||
.from(snippets)
|
||||
.where(eq(snippets.userId, userId))
|
||||
.orderBy(desc(snippets.updatedAt));
|
||||
.orderBy(
|
||||
sql`CASE WHEN ${snippets.folder} IS NULL OR ${snippets.folder} = '' THEN 0 ELSE 1 END`,
|
||||
asc(snippets.folder),
|
||||
asc(snippets.order),
|
||||
desc(snippets.updatedAt),
|
||||
);
|
||||
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
@@ -93,7 +743,7 @@ router.post(
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { name, content, description } = req.body;
|
||||
const { name, content, description, folder, order } = req.body;
|
||||
|
||||
if (
|
||||
!isNonEmptyString(userId) ||
|
||||
@@ -110,11 +760,31 @@ router.post(
|
||||
}
|
||||
|
||||
try {
|
||||
let snippetOrder = order;
|
||||
if (snippetOrder === undefined || snippetOrder === null) {
|
||||
const folderValue = folder?.trim() || "";
|
||||
const maxOrderResult = await db
|
||||
.select({ maxOrder: sql<number>`MAX(${snippets.order})` })
|
||||
.from(snippets)
|
||||
.where(
|
||||
and(
|
||||
eq(snippets.userId, userId),
|
||||
folderValue
|
||||
? eq(snippets.folder, folderValue)
|
||||
: sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`,
|
||||
),
|
||||
);
|
||||
const maxOrder = maxOrderResult[0]?.maxOrder ?? -1;
|
||||
snippetOrder = maxOrder + 1;
|
||||
}
|
||||
|
||||
const insertData = {
|
||||
userId,
|
||||
name: name.trim(),
|
||||
content: content.trim(),
|
||||
description: description?.trim() || null,
|
||||
folder: folder?.trim() || null,
|
||||
order: snippetOrder,
|
||||
};
|
||||
|
||||
const result = await db.insert(snippets).values(insertData).returning();
|
||||
@@ -167,6 +837,8 @@ router.put(
|
||||
name: string;
|
||||
content: string;
|
||||
description: string | null;
|
||||
folder: string | null;
|
||||
order: number;
|
||||
}> = {
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
};
|
||||
@@ -177,6 +849,9 @@ router.put(
|
||||
updateFields.content = updateData.content.trim();
|
||||
if (updateData.description !== undefined)
|
||||
updateFields.description = updateData.description?.trim() || null;
|
||||
if (updateData.folder !== undefined)
|
||||
updateFields.folder = updateData.folder?.trim() || null;
|
||||
if (updateData.order !== undefined) updateFields.order = updateData.order;
|
||||
|
||||
await db
|
||||
.update(snippets)
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
fileManagerRecent,
|
||||
fileManagerPinned,
|
||||
fileManagerShortcuts,
|
||||
sshFolders,
|
||||
commandHistory,
|
||||
recentActivity,
|
||||
} from "../db/schema.js";
|
||||
import { eq, and, desc, isNotNull, or } from "drizzle-orm";
|
||||
import type { Request, Response } from "express";
|
||||
@@ -215,6 +218,7 @@ router.post(
|
||||
}
|
||||
|
||||
const {
|
||||
connectionType,
|
||||
name,
|
||||
folder,
|
||||
tags,
|
||||
@@ -232,11 +236,20 @@ router.post(
|
||||
enableTerminal,
|
||||
enableTunnel,
|
||||
enableFileManager,
|
||||
enableDocker,
|
||||
defaultPath,
|
||||
tunnelConnections,
|
||||
jumpHosts,
|
||||
quickActions,
|
||||
statsConfig,
|
||||
dockerConfig,
|
||||
terminalConfig,
|
||||
forceKeyboardInteractive,
|
||||
// RDP/VNC specific fields
|
||||
domain,
|
||||
security,
|
||||
ignoreCert,
|
||||
guacamoleConfig,
|
||||
} = hostData;
|
||||
if (
|
||||
!isNonEmptyString(userId) ||
|
||||
@@ -254,8 +267,10 @@ router.post(
|
||||
}
|
||||
|
||||
const effectiveAuthType = authType || authMethod;
|
||||
const effectiveConnectionType = connectionType || "ssh";
|
||||
const sshDataObj: Record<string, unknown> = {
|
||||
userId: userId,
|
||||
connectionType: effectiveConnectionType,
|
||||
name,
|
||||
folder: folder || null,
|
||||
tags: Array.isArray(tags) ? tags.join(",") : tags || "",
|
||||
@@ -270,11 +285,22 @@ router.post(
|
||||
tunnelConnections: Array.isArray(tunnelConnections)
|
||||
? JSON.stringify(tunnelConnections)
|
||||
: null,
|
||||
jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null,
|
||||
quickActions: Array.isArray(quickActions)
|
||||
? JSON.stringify(quickActions)
|
||||
: null,
|
||||
enableFileManager: enableFileManager ? 1 : 0,
|
||||
enableDocker: enableDocker ? 1 : 0,
|
||||
defaultPath: defaultPath || null,
|
||||
statsConfig: statsConfig ? JSON.stringify(statsConfig) : null,
|
||||
dockerConfig: dockerConfig ? JSON.stringify(dockerConfig) : null,
|
||||
terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null,
|
||||
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
|
||||
// RDP/VNC specific fields
|
||||
domain: domain || null,
|
||||
security: security || null,
|
||||
ignoreCert: ignoreCert ? 1 : 0,
|
||||
guacamoleConfig: guacamoleConfig ? JSON.stringify(guacamoleConfig) : null,
|
||||
};
|
||||
|
||||
if (effectiveAuthType === "password") {
|
||||
@@ -328,10 +354,20 @@ router.post(
|
||||
tunnelConnections: createdHost.tunnelConnections
|
||||
? JSON.parse(createdHost.tunnelConnections as string)
|
||||
: [],
|
||||
jumpHosts: createdHost.jumpHosts
|
||||
? JSON.parse(createdHost.jumpHosts as string)
|
||||
: [],
|
||||
enableFileManager: !!createdHost.enableFileManager,
|
||||
enableDocker: !!createdHost.enableDocker,
|
||||
statsConfig: createdHost.statsConfig
|
||||
? JSON.parse(createdHost.statsConfig as string)
|
||||
: undefined,
|
||||
dockerConfig: createdHost.dockerConfig
|
||||
? JSON.parse(createdHost.dockerConfig as string)
|
||||
: undefined,
|
||||
guacamoleConfig: createdHost.guacamoleConfig
|
||||
? JSON.parse(createdHost.guacamoleConfig as string)
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const resolvedHost = (await resolveHostCredentials(baseHost)) || baseHost;
|
||||
@@ -349,6 +385,28 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const axios = (await import("axios")).default;
|
||||
const statsPort = process.env.STATS_PORT || 30005;
|
||||
await axios.post(
|
||||
`http://localhost:${statsPort}/host-updated`,
|
||||
{ hostId: createdHost.id },
|
||||
{
|
||||
headers: {
|
||||
Authorization: req.headers.authorization || "",
|
||||
Cookie: req.headers.cookie || "",
|
||||
},
|
||||
timeout: 5000,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
sshLogger.warn("Failed to notify stats server of new host", {
|
||||
operation: "host_create",
|
||||
hostId: createdHost.id as number,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
res.json(resolvedHost);
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to save SSH host to database", err, {
|
||||
@@ -369,6 +427,7 @@ router.post(
|
||||
router.put(
|
||||
"/db/host/:id",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
upload.single("key"),
|
||||
async (req: Request, res: Response) => {
|
||||
const hostId = req.params.id;
|
||||
@@ -405,6 +464,7 @@ router.put(
|
||||
}
|
||||
|
||||
const {
|
||||
connectionType,
|
||||
name,
|
||||
folder,
|
||||
tags,
|
||||
@@ -422,11 +482,20 @@ router.put(
|
||||
enableTerminal,
|
||||
enableTunnel,
|
||||
enableFileManager,
|
||||
enableDocker,
|
||||
defaultPath,
|
||||
tunnelConnections,
|
||||
jumpHosts,
|
||||
quickActions,
|
||||
statsConfig,
|
||||
dockerConfig,
|
||||
terminalConfig,
|
||||
forceKeyboardInteractive,
|
||||
// RDP/VNC specific fields
|
||||
domain,
|
||||
security,
|
||||
ignoreCert,
|
||||
guacamoleConfig,
|
||||
} = hostData;
|
||||
if (
|
||||
!isNonEmptyString(userId) ||
|
||||
@@ -447,6 +516,7 @@ router.put(
|
||||
|
||||
const effectiveAuthType = authType || authMethod;
|
||||
const sshDataObj: Record<string, unknown> = {
|
||||
connectionType: connectionType || "ssh",
|
||||
name,
|
||||
folder,
|
||||
tags: Array.isArray(tags) ? tags.join(",") : tags || "",
|
||||
@@ -461,11 +531,22 @@ router.put(
|
||||
tunnelConnections: Array.isArray(tunnelConnections)
|
||||
? JSON.stringify(tunnelConnections)
|
||||
: null,
|
||||
jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null,
|
||||
quickActions: Array.isArray(quickActions)
|
||||
? JSON.stringify(quickActions)
|
||||
: null,
|
||||
enableFileManager: enableFileManager ? 1 : 0,
|
||||
enableDocker: enableDocker ? 1 : 0,
|
||||
defaultPath: defaultPath || null,
|
||||
statsConfig: statsConfig ? JSON.stringify(statsConfig) : null,
|
||||
dockerConfig: dockerConfig ? JSON.stringify(dockerConfig) : null,
|
||||
terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null,
|
||||
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
|
||||
// RDP/VNC specific fields
|
||||
domain: domain || null,
|
||||
security: security || null,
|
||||
ignoreCert: ignoreCert ? 1 : 0,
|
||||
guacamoleConfig: guacamoleConfig ? JSON.stringify(guacamoleConfig) : null,
|
||||
};
|
||||
|
||||
if (effectiveAuthType === "password") {
|
||||
@@ -537,10 +618,20 @@ router.put(
|
||||
tunnelConnections: updatedHost.tunnelConnections
|
||||
? JSON.parse(updatedHost.tunnelConnections as string)
|
||||
: [],
|
||||
jumpHosts: updatedHost.jumpHosts
|
||||
? JSON.parse(updatedHost.jumpHosts as string)
|
||||
: [],
|
||||
enableFileManager: !!updatedHost.enableFileManager,
|
||||
enableDocker: !!updatedHost.enableDocker,
|
||||
statsConfig: updatedHost.statsConfig
|
||||
? JSON.parse(updatedHost.statsConfig as string)
|
||||
: undefined,
|
||||
dockerConfig: updatedHost.dockerConfig
|
||||
? JSON.parse(updatedHost.dockerConfig as string)
|
||||
: undefined,
|
||||
guacamoleConfig: updatedHost.guacamoleConfig
|
||||
? JSON.parse(updatedHost.guacamoleConfig as string)
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const resolvedHost = (await resolveHostCredentials(baseHost)) || baseHost;
|
||||
@@ -558,6 +649,28 @@ router.put(
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const axios = (await import("axios")).default;
|
||||
const statsPort = process.env.STATS_PORT || 30005;
|
||||
await axios.post(
|
||||
`http://localhost:${statsPort}/host-updated`,
|
||||
{ hostId: parseInt(hostId) },
|
||||
{
|
||||
headers: {
|
||||
Authorization: req.headers.authorization || "",
|
||||
Cookie: req.headers.cookie || "",
|
||||
},
|
||||
timeout: 5000,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
sshLogger.warn("Failed to notify stats server of host update", {
|
||||
operation: "host_update",
|
||||
hostId: parseInt(hostId),
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
res.json(resolvedHost);
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to update SSH host in database", err, {
|
||||
@@ -576,67 +689,84 @@ router.put(
|
||||
|
||||
// Route: Get SSH data for the authenticated user (requires JWT)
|
||||
// GET /ssh/host
|
||||
router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
if (!isNonEmptyString(userId)) {
|
||||
sshLogger.warn("Invalid userId for SSH data fetch", {
|
||||
operation: "host_fetch",
|
||||
userId,
|
||||
});
|
||||
return res.status(400).json({ error: "Invalid userId" });
|
||||
}
|
||||
try {
|
||||
const data = await SimpleDBOps.select(
|
||||
db.select().from(sshData).where(eq(sshData.userId, userId)),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
router.get(
|
||||
"/db/host",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
if (!isNonEmptyString(userId)) {
|
||||
sshLogger.warn("Invalid userId for SSH data fetch", {
|
||||
operation: "host_fetch",
|
||||
userId,
|
||||
});
|
||||
return res.status(400).json({ error: "Invalid userId" });
|
||||
}
|
||||
try {
|
||||
const data = await SimpleDBOps.select(
|
||||
db.select().from(sshData).where(eq(sshData.userId, userId)),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
|
||||
const result = await Promise.all(
|
||||
data.map(async (row: Record<string, unknown>) => {
|
||||
const baseHost = {
|
||||
...row,
|
||||
tags:
|
||||
typeof row.tags === "string"
|
||||
? row.tags
|
||||
? row.tags.split(",").filter(Boolean)
|
||||
: []
|
||||
const result = await Promise.all(
|
||||
data.map(async (row: Record<string, unknown>) => {
|
||||
const baseHost = {
|
||||
...row,
|
||||
tags:
|
||||
typeof row.tags === "string"
|
||||
? row.tags
|
||||
? row.tags.split(",").filter(Boolean)
|
||||
: []
|
||||
: [],
|
||||
pin: !!row.pin,
|
||||
enableTerminal: !!row.enableTerminal,
|
||||
enableTunnel: !!row.enableTunnel,
|
||||
tunnelConnections: row.tunnelConnections
|
||||
? JSON.parse(row.tunnelConnections as string)
|
||||
: [],
|
||||
pin: !!row.pin,
|
||||
enableTerminal: !!row.enableTerminal,
|
||||
enableTunnel: !!row.enableTunnel,
|
||||
tunnelConnections: row.tunnelConnections
|
||||
? JSON.parse(row.tunnelConnections as string)
|
||||
: [],
|
||||
enableFileManager: !!row.enableFileManager,
|
||||
statsConfig: row.statsConfig
|
||||
? JSON.parse(row.statsConfig as string)
|
||||
: undefined,
|
||||
terminalConfig: row.terminalConfig
|
||||
? JSON.parse(row.terminalConfig as string)
|
||||
: undefined,
|
||||
forceKeyboardInteractive: row.forceKeyboardInteractive === "true",
|
||||
};
|
||||
jumpHosts: row.jumpHosts ? JSON.parse(row.jumpHosts as string) : [],
|
||||
quickActions: row.quickActions
|
||||
? JSON.parse(row.quickActions as string)
|
||||
: [],
|
||||
enableFileManager: !!row.enableFileManager,
|
||||
enableDocker: !!row.enableDocker,
|
||||
statsConfig: row.statsConfig
|
||||
? JSON.parse(row.statsConfig as string)
|
||||
: undefined,
|
||||
dockerConfig: row.dockerConfig
|
||||
? JSON.parse(row.dockerConfig as string)
|
||||
: undefined,
|
||||
terminalConfig: row.terminalConfig
|
||||
? JSON.parse(row.terminalConfig as string)
|
||||
: undefined,
|
||||
guacamoleConfig: row.guacamoleConfig
|
||||
? JSON.parse(row.guacamoleConfig as string)
|
||||
: undefined,
|
||||
forceKeyboardInteractive: row.forceKeyboardInteractive === "true",
|
||||
};
|
||||
|
||||
return (await resolveHostCredentials(baseHost)) || baseHost;
|
||||
}),
|
||||
);
|
||||
return (await resolveHostCredentials(baseHost)) || baseHost;
|
||||
}),
|
||||
);
|
||||
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to fetch SSH hosts from database", err, {
|
||||
operation: "host_fetch",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to fetch SSH data" });
|
||||
}
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to fetch SSH hosts from database", err, {
|
||||
operation: "host_fetch",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to fetch SSH data" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Route: Get SSH host by ID (requires JWT)
|
||||
// GET /ssh/host/:id
|
||||
router.get(
|
||||
"/db/host/:id",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const hostId = req.params.id;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
@@ -679,6 +809,8 @@ router.get(
|
||||
tunnelConnections: host.tunnelConnections
|
||||
? JSON.parse(host.tunnelConnections)
|
||||
: [],
|
||||
jumpHosts: host.jumpHosts ? JSON.parse(host.jumpHosts) : [],
|
||||
quickActions: host.quickActions ? JSON.parse(host.quickActions) : [],
|
||||
enableFileManager: !!host.enableFileManager,
|
||||
statsConfig: host.statsConfig
|
||||
? JSON.parse(host.statsConfig)
|
||||
@@ -686,6 +818,9 @@ router.get(
|
||||
terminalConfig: host.terminalConfig
|
||||
? JSON.parse(host.terminalConfig)
|
||||
: undefined,
|
||||
guacamoleConfig: host.guacamoleConfig
|
||||
? JSON.parse(host.guacamoleConfig)
|
||||
: undefined,
|
||||
forceKeyboardInteractive: host.forceKeyboardInteractive === "true",
|
||||
};
|
||||
|
||||
@@ -783,6 +918,7 @@ router.get(
|
||||
router.delete(
|
||||
"/db/host/:id",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const hostId = req.params.id;
|
||||
@@ -816,8 +952,8 @@ router.delete(
|
||||
.delete(fileManagerRecent)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerRecent.userId, userId),
|
||||
eq(fileManagerRecent.hostId, numericHostId),
|
||||
eq(fileManagerRecent.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -825,8 +961,8 @@ router.delete(
|
||||
.delete(fileManagerPinned)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerPinned.userId, userId),
|
||||
eq(fileManagerPinned.hostId, numericHostId),
|
||||
eq(fileManagerPinned.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -834,8 +970,17 @@ router.delete(
|
||||
.delete(fileManagerShortcuts)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerShortcuts.userId, userId),
|
||||
eq(fileManagerShortcuts.hostId, numericHostId),
|
||||
eq(fileManagerShortcuts.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
await db
|
||||
.delete(commandHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(commandHistory.hostId, numericHostId),
|
||||
eq(commandHistory.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -843,8 +988,17 @@ router.delete(
|
||||
.delete(sshCredentialUsage)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentialUsage.userId, userId),
|
||||
eq(sshCredentialUsage.hostId, numericHostId),
|
||||
eq(sshCredentialUsage.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
await db
|
||||
.delete(recentActivity)
|
||||
.where(
|
||||
and(
|
||||
eq(recentActivity.hostId, numericHostId),
|
||||
eq(recentActivity.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -865,6 +1019,28 @@ router.delete(
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const axios = (await import("axios")).default;
|
||||
const statsPort = process.env.STATS_PORT || 30005;
|
||||
await axios.post(
|
||||
`http://localhost:${statsPort}/host-deleted`,
|
||||
{ hostId: numericHostId },
|
||||
{
|
||||
headers: {
|
||||
Authorization: req.headers.authorization || "",
|
||||
Cookie: req.headers.cookie || "",
|
||||
},
|
||||
timeout: 5000,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
sshLogger.warn("Failed to notify stats server of host deletion", {
|
||||
operation: "host_delete",
|
||||
hostId: numericHostId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ message: "SSH host deleted" });
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to delete SSH host from database", err, {
|
||||
@@ -1241,6 +1417,94 @@ router.delete(
|
||||
},
|
||||
);
|
||||
|
||||
// Route: Get command history for a host
|
||||
// GET /ssh/command-history/:hostId
|
||||
router.get(
|
||||
"/command-history/:hostId",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const hostId = parseInt(req.params.hostId, 10);
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId) {
|
||||
sshLogger.warn("Invalid userId or hostId for command history fetch", {
|
||||
operation: "command_history_fetch",
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
return res.status(400).json({ error: "Invalid userId or hostId" });
|
||||
}
|
||||
|
||||
try {
|
||||
const history = await db
|
||||
.select({
|
||||
id: commandHistory.id,
|
||||
command: commandHistory.command,
|
||||
})
|
||||
.from(commandHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(commandHistory.userId, userId),
|
||||
eq(commandHistory.hostId, hostId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(commandHistory.executedAt))
|
||||
.limit(200);
|
||||
|
||||
res.json(history.map((h) => h.command));
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to fetch command history from database", err, {
|
||||
operation: "command_history_fetch",
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to fetch command history" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Route: Delete command from history
|
||||
// DELETE /ssh/command-history
|
||||
router.delete(
|
||||
"/command-history",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hostId, command } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId || !command) {
|
||||
sshLogger.warn("Invalid data for command history deletion", {
|
||||
operation: "command_history_delete",
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
return res.status(400).json({ error: "Invalid data" });
|
||||
}
|
||||
|
||||
try {
|
||||
await db
|
||||
.delete(commandHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(commandHistory.userId, userId),
|
||||
eq(commandHistory.hostId, hostId),
|
||||
eq(commandHistory.command, command),
|
||||
),
|
||||
);
|
||||
|
||||
res.json({ message: "Command deleted from history" });
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to delete command from history", err, {
|
||||
operation: "command_history_delete",
|
||||
hostId,
|
||||
userId,
|
||||
command,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to delete command" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function resolveHostCredentials(
|
||||
host: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
@@ -1341,6 +1605,16 @@ router.put(
|
||||
|
||||
DatabaseSaveTrigger.triggerSave("folder_rename");
|
||||
|
||||
await db
|
||||
.update(sshFolders)
|
||||
.set({
|
||||
name: newName,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(
|
||||
and(eq(sshFolders.userId, userId), eq(sshFolders.name, oldName)),
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: "Folder renamed successfully",
|
||||
updatedHosts: updatedHosts.length,
|
||||
@@ -1358,6 +1632,170 @@ router.put(
|
||||
},
|
||||
);
|
||||
|
||||
// Route: Get all folders with metadata (requires JWT)
|
||||
// GET /ssh/db/folders
|
||||
router.get("/folders", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!isNonEmptyString(userId)) {
|
||||
return res.status(400).json({ error: "Invalid user ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
const folders = await db
|
||||
.select()
|
||||
.from(sshFolders)
|
||||
.where(eq(sshFolders.userId, userId));
|
||||
|
||||
res.json(folders);
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to fetch folders", err, {
|
||||
operation: "fetch_folders",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to fetch folders" });
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Update folder metadata (requires JWT)
|
||||
// PUT /ssh/db/folders/metadata
|
||||
router.put(
|
||||
"/folders/metadata",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { name, color, icon } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !name) {
|
||||
return res.status(400).json({ error: "Folder name is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(sshFolders)
|
||||
.where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
await db
|
||||
.update(sshFolders)
|
||||
.set({
|
||||
color,
|
||||
icon,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)));
|
||||
} else {
|
||||
await db.insert(sshFolders).values({
|
||||
userId,
|
||||
name,
|
||||
color,
|
||||
icon,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
DatabaseSaveTrigger.triggerSave("folder_metadata_update");
|
||||
|
||||
res.json({ message: "Folder metadata updated successfully" });
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to update folder metadata", err, {
|
||||
operation: "update_folder_metadata",
|
||||
userId,
|
||||
name,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to update folder metadata" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Route: Delete all hosts in folder (requires JWT)
|
||||
// DELETE /ssh/db/folders/:name/hosts
|
||||
router.delete(
|
||||
"/folders/:name/hosts",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const folderName = req.params.name;
|
||||
|
||||
if (!isNonEmptyString(userId) || !folderName) {
|
||||
return res.status(400).json({ error: "Invalid folder name" });
|
||||
}
|
||||
|
||||
try {
|
||||
const hostsToDelete = await db
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(and(eq(sshData.userId, userId), eq(sshData.folder, folderName)));
|
||||
|
||||
if (hostsToDelete.length === 0) {
|
||||
return res.json({
|
||||
message: "No hosts found in folder",
|
||||
deletedCount: 0,
|
||||
});
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(sshData)
|
||||
.where(and(eq(sshData.userId, userId), eq(sshData.folder, folderName)));
|
||||
|
||||
await db
|
||||
.delete(sshFolders)
|
||||
.where(
|
||||
and(eq(sshFolders.userId, userId), eq(sshFolders.name, folderName)),
|
||||
);
|
||||
|
||||
DatabaseSaveTrigger.triggerSave("folder_hosts_delete");
|
||||
|
||||
try {
|
||||
const axios = (await import("axios")).default;
|
||||
const statsPort = process.env.STATS_PORT || 30005;
|
||||
for (const host of hostsToDelete) {
|
||||
try {
|
||||
await axios.post(
|
||||
`http://localhost:${statsPort}/host-deleted`,
|
||||
{ hostId: host.id },
|
||||
{
|
||||
headers: {
|
||||
Authorization: req.headers.authorization || "",
|
||||
Cookie: req.headers.cookie || "",
|
||||
},
|
||||
timeout: 5000,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
sshLogger.warn("Failed to notify stats server of host deletion", {
|
||||
operation: "folder_hosts_delete",
|
||||
hostId: host.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
sshLogger.warn("Failed to notify stats server of folder deletion", {
|
||||
operation: "folder_hosts_delete",
|
||||
folderName,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: "All hosts in folder deleted successfully",
|
||||
deletedCount: hostsToDelete.length,
|
||||
});
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to delete hosts in folder", err, {
|
||||
operation: "delete_folder_hosts",
|
||||
userId,
|
||||
folderName,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to delete hosts in folder" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Route: Bulk import SSH hosts (requires JWT)
|
||||
// POST /ssh/bulk-import
|
||||
router.post(
|
||||
|
||||
195
src/backend/database/routes/terminal.ts
Normal file
195
src/backend/database/routes/terminal.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import express from "express";
|
||||
import { db } from "../db/index.js";
|
||||
import { commandHistory } from "../db/schema.js";
|
||||
import { eq, and, desc, sql } from "drizzle-orm";
|
||||
import type { Request, Response } from "express";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function isNonEmptyString(val: unknown): val is string {
|
||||
return typeof val === "string" && val.trim().length > 0;
|
||||
}
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||
|
||||
// Save command to history
|
||||
// POST /terminal/command_history
|
||||
router.post(
|
||||
"/command_history",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hostId, command } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId || !isNonEmptyString(command)) {
|
||||
authLogger.warn("Invalid command history save request", {
|
||||
operation: "command_history_save",
|
||||
userId,
|
||||
hasHostId: !!hostId,
|
||||
hasCommand: !!command,
|
||||
});
|
||||
return res.status(400).json({ error: "Missing required parameters" });
|
||||
}
|
||||
|
||||
try {
|
||||
const insertData = {
|
||||
userId,
|
||||
hostId: parseInt(hostId, 10),
|
||||
command: command.trim(),
|
||||
};
|
||||
|
||||
const result = await db
|
||||
.insert(commandHistory)
|
||||
.values(insertData)
|
||||
.returning();
|
||||
|
||||
res.status(201).json(result[0]);
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to save command to history", err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : "Failed to save command",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Get command history for a specific host
|
||||
// GET /terminal/command_history/:hostId
|
||||
router.get(
|
||||
"/command_history/:hostId",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hostId } = req.params;
|
||||
const hostIdNum = parseInt(hostId, 10);
|
||||
|
||||
if (!isNonEmptyString(userId) || isNaN(hostIdNum)) {
|
||||
authLogger.warn("Invalid command history fetch request", {
|
||||
userId,
|
||||
hostId: hostIdNum,
|
||||
});
|
||||
return res.status(400).json({ error: "Invalid request parameters" });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await db
|
||||
.select({
|
||||
command: commandHistory.command,
|
||||
maxExecutedAt: sql<number>`MAX(${commandHistory.executedAt})`,
|
||||
})
|
||||
.from(commandHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(commandHistory.userId, userId),
|
||||
eq(commandHistory.hostId, hostIdNum),
|
||||
),
|
||||
)
|
||||
.groupBy(commandHistory.command)
|
||||
.orderBy(desc(sql`MAX(${commandHistory.executedAt})`))
|
||||
.limit(500);
|
||||
|
||||
const uniqueCommands = result.map((r) => r.command);
|
||||
|
||||
res.json(uniqueCommands);
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to fetch command history", err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : "Failed to fetch history",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Delete a specific command from history
|
||||
// POST /terminal/command_history/delete
|
||||
router.post(
|
||||
"/command_history/delete",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hostId, command } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId || !isNonEmptyString(command)) {
|
||||
authLogger.warn("Invalid command delete request", {
|
||||
operation: "command_history_delete",
|
||||
userId,
|
||||
hasHostId: !!hostId,
|
||||
hasCommand: !!command,
|
||||
});
|
||||
return res.status(400).json({ error: "Missing required parameters" });
|
||||
}
|
||||
|
||||
try {
|
||||
const hostIdNum = parseInt(hostId, 10);
|
||||
|
||||
await db
|
||||
.delete(commandHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(commandHistory.userId, userId),
|
||||
eq(commandHistory.hostId, hostIdNum),
|
||||
eq(commandHistory.command, command.trim()),
|
||||
),
|
||||
);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to delete command from history", err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : "Failed to delete command",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Clear command history for a specific host (optional feature)
|
||||
// DELETE /terminal/command_history/:hostId
|
||||
router.delete(
|
||||
"/command_history/:hostId",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hostId } = req.params;
|
||||
const hostIdNum = parseInt(hostId, 10);
|
||||
|
||||
if (!isNonEmptyString(userId) || isNaN(hostIdNum)) {
|
||||
authLogger.warn("Invalid command history clear request");
|
||||
return res.status(400).json({ error: "Invalid request" });
|
||||
}
|
||||
|
||||
try {
|
||||
await db
|
||||
.delete(commandHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(commandHistory.userId, userId),
|
||||
eq(commandHistory.hostId, hostIdNum),
|
||||
),
|
||||
);
|
||||
|
||||
authLogger.success(`Command history cleared for host ${hostId}`, {
|
||||
operation: "command_history_clear_success",
|
||||
userId,
|
||||
hostId: hostIdNum,
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to clear command history", err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : "Failed to clear history",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -22,11 +22,12 @@ import { nanoid } from "nanoid";
|
||||
import speakeasy from "speakeasy";
|
||||
import QRCode from "qrcode";
|
||||
import type { Request, Response } from "express";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { authLogger, databaseLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js";
|
||||
import { parseUserAgent } from "../../utils/user-agent-parser.js";
|
||||
import { loginRateLimiter } from "../../utils/login-rate-limiter.js";
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
@@ -226,6 +227,16 @@ router.post("/create", async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (saveError) {
|
||||
authLogger.error("Failed to persist user to disk", saveError, {
|
||||
operation: "user_create_save_failed",
|
||||
userId: id,
|
||||
});
|
||||
}
|
||||
|
||||
authLogger.success(
|
||||
`Traditional user created: ${username} (is_admin: ${isFirstUser})`,
|
||||
{
|
||||
@@ -587,6 +598,7 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
@@ -736,12 +748,11 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const deviceInfo = parseUserAgent(req);
|
||||
let user = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(
|
||||
and(eq(users.is_oidc, true), eq(users.oidc_identifier, identifier)),
|
||||
);
|
||||
.where(eq(users.oidc_identifier, identifier));
|
||||
|
||||
let isFirstUser = false;
|
||||
if (!user || user.length === 0) {
|
||||
@@ -750,6 +761,43 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
.get();
|
||||
isFirstUser = ((countResult as { count?: number })?.count || 0) === 0;
|
||||
|
||||
if (!isFirstUser) {
|
||||
try {
|
||||
const regRow = db.$client
|
||||
.prepare(
|
||||
"SELECT value FROM settings WHERE key = 'allow_registration'",
|
||||
)
|
||||
.get();
|
||||
if (regRow && (regRow as Record<string, unknown>).value !== "true") {
|
||||
authLogger.warn(
|
||||
"OIDC user attempted to register when registration is disabled",
|
||||
{
|
||||
operation: "oidc_registration_disabled",
|
||||
identifier,
|
||||
name,
|
||||
},
|
||||
);
|
||||
|
||||
let frontendUrl = (redirectUri as string).replace(
|
||||
"/users/oidc/callback",
|
||||
"",
|
||||
);
|
||||
if (frontendUrl.includes("localhost")) {
|
||||
frontendUrl = "http://localhost:5173";
|
||||
}
|
||||
const redirectUrl = new URL(frontendUrl);
|
||||
redirectUrl.searchParams.set("error", "registration_disabled");
|
||||
|
||||
return res.redirect(redirectUrl.toString());
|
||||
}
|
||||
} catch (e) {
|
||||
authLogger.warn("Failed to check registration status during OIDC", {
|
||||
operation: "oidc_registration_check",
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const id = nanoid();
|
||||
await db.insert(users).values({
|
||||
id,
|
||||
@@ -769,7 +817,11 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
});
|
||||
|
||||
try {
|
||||
await authManager.registerOIDCUser(id);
|
||||
const sessionDurationMs =
|
||||
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
: 7 * 24 * 60 * 60 * 1000;
|
||||
await authManager.registerOIDCUser(id, sessionDurationMs);
|
||||
} catch (encryptionError) {
|
||||
await db.delete(users).where(eq(users.id, id));
|
||||
authLogger.error(
|
||||
@@ -785,12 +837,27 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (saveError) {
|
||||
authLogger.error("Failed to persist OIDC user to disk", saveError, {
|
||||
operation: "oidc_user_create_save_failed",
|
||||
userId: id,
|
||||
});
|
||||
}
|
||||
|
||||
user = await db.select().from(users).where(eq(users.id, id));
|
||||
} else {
|
||||
await db
|
||||
.update(users)
|
||||
.set({ username: name })
|
||||
.where(eq(users.id, user[0].id));
|
||||
const isDualAuth =
|
||||
user[0].password_hash && user[0].password_hash.trim() !== "";
|
||||
|
||||
if (!isDualAuth) {
|
||||
await db
|
||||
.update(users)
|
||||
.set({ username: name })
|
||||
.where(eq(users.id, user[0].id));
|
||||
}
|
||||
|
||||
user = await db.select().from(users).where(eq(users.id, user[0].id));
|
||||
}
|
||||
@@ -798,7 +865,7 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
const userRecord = user[0];
|
||||
|
||||
try {
|
||||
await authManager.authenticateOIDCUser(userRecord.id);
|
||||
await authManager.authenticateOIDCUser(userRecord.id, deviceInfo.type);
|
||||
} catch (setupError) {
|
||||
authLogger.error("Failed to setup OIDC user encryption", setupError, {
|
||||
operation: "oidc_user_encryption_setup_failed",
|
||||
@@ -806,7 +873,6 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const deviceInfo = parseUserAgent(req);
|
||||
const token = await authManager.generateJWTToken(userRecord.id, {
|
||||
deviceType: deviceInfo.type,
|
||||
deviceInfo: deviceInfo.deviceInfo,
|
||||
@@ -836,6 +902,8 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
: 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
res.clearCookie("jwt", authManager.getSecureCookieOptions(req));
|
||||
|
||||
return res
|
||||
.cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge))
|
||||
.redirect(redirectUrl.toString());
|
||||
@@ -862,6 +930,7 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
// POST /users/login
|
||||
router.post("/login", async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
const clientIp = req.ip || req.socket.remoteAddress || "unknown";
|
||||
|
||||
if (!isNonEmptyString(username) || !isNonEmptyString(password)) {
|
||||
authLogger.warn("Invalid traditional login attempt", {
|
||||
@@ -872,6 +941,20 @@ router.post("/login", async (req, res) => {
|
||||
return res.status(400).json({ error: "Invalid username or password" });
|
||||
}
|
||||
|
||||
const lockStatus = loginRateLimiter.isLocked(clientIp, username);
|
||||
if (lockStatus.locked) {
|
||||
authLogger.warn("Login attempt blocked due to rate limiting", {
|
||||
operation: "user_login_blocked",
|
||||
username,
|
||||
ip: clientIp,
|
||||
remainingTime: lockStatus.remainingTime,
|
||||
});
|
||||
return res.status(429).json({
|
||||
error: "Too many login attempts. Please try again later.",
|
||||
remainingTime: lockStatus.remainingTime,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const row = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'allow_password_login'")
|
||||
@@ -896,17 +979,26 @@ router.post("/login", async (req, res) => {
|
||||
.where(eq(users.username, username));
|
||||
|
||||
if (!user || user.length === 0) {
|
||||
authLogger.warn(`User not found: ${username}`, {
|
||||
loginRateLimiter.recordFailedAttempt(clientIp, username);
|
||||
authLogger.warn(`Login failed: user not found`, {
|
||||
operation: "user_login",
|
||||
username,
|
||||
ip: clientIp,
|
||||
remainingAttempts: loginRateLimiter.getRemainingAttempts(
|
||||
clientIp,
|
||||
username,
|
||||
),
|
||||
});
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
return res.status(401).json({ error: "Invalid username or password" });
|
||||
}
|
||||
|
||||
const userRecord = user[0];
|
||||
|
||||
if (userRecord.is_oidc) {
|
||||
authLogger.warn("OIDC user attempted traditional login", {
|
||||
if (
|
||||
userRecord.is_oidc &&
|
||||
(!userRecord.password_hash || userRecord.password_hash.trim() === "")
|
||||
) {
|
||||
authLogger.warn("OIDC-only user attempted traditional login", {
|
||||
operation: "user_login",
|
||||
username,
|
||||
userId: userRecord.id,
|
||||
@@ -918,12 +1010,18 @@ router.post("/login", async (req, res) => {
|
||||
|
||||
const isMatch = await bcrypt.compare(password, userRecord.password_hash);
|
||||
if (!isMatch) {
|
||||
authLogger.warn(`Incorrect password for user: ${username}`, {
|
||||
loginRateLimiter.recordFailedAttempt(clientIp, username);
|
||||
authLogger.warn(`Login failed: incorrect password`, {
|
||||
operation: "user_login",
|
||||
username,
|
||||
userId: userRecord.id,
|
||||
ip: clientIp,
|
||||
remainingAttempts: loginRateLimiter.getRemainingAttempts(
|
||||
clientIp,
|
||||
username,
|
||||
),
|
||||
});
|
||||
return res.status(401).json({ error: "Incorrect password" });
|
||||
return res.status(401).json({ error: "Invalid username or password" });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -935,12 +1033,24 @@ router.post("/login", async (req, res) => {
|
||||
if (kekSalt.length === 0) {
|
||||
await authManager.registerUser(userRecord.id, password);
|
||||
}
|
||||
} catch {}
|
||||
} catch (error) {}
|
||||
|
||||
const deviceInfo = parseUserAgent(req);
|
||||
|
||||
let dataUnlocked = false;
|
||||
if (userRecord.is_oidc) {
|
||||
dataUnlocked = await authManager.authenticateOIDCUser(
|
||||
userRecord.id,
|
||||
deviceInfo.type,
|
||||
);
|
||||
} else {
|
||||
dataUnlocked = await authManager.authenticateUser(
|
||||
userRecord.id,
|
||||
password,
|
||||
deviceInfo.type,
|
||||
);
|
||||
}
|
||||
|
||||
const dataUnlocked = await authManager.authenticateUser(
|
||||
userRecord.id,
|
||||
password,
|
||||
);
|
||||
if (!dataUnlocked) {
|
||||
return res.status(401).json({ error: "Incorrect password" });
|
||||
}
|
||||
@@ -957,12 +1067,13 @@ router.post("/login", async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const deviceInfo = parseUserAgent(req);
|
||||
const token = await authManager.generateJWTToken(userRecord.id, {
|
||||
deviceType: deviceInfo.type,
|
||||
deviceInfo: deviceInfo.deviceInfo,
|
||||
});
|
||||
|
||||
loginRateLimiter.resetAttempts(clientIp, username);
|
||||
|
||||
authLogger.success(`User logged in successfully: ${username}`, {
|
||||
operation: "user_login_success",
|
||||
username,
|
||||
@@ -970,6 +1081,7 @@ router.post("/login", async (req, res) => {
|
||||
dataUnlocked: true,
|
||||
deviceType: deviceInfo.type,
|
||||
deviceInfo: deviceInfo.deviceInfo,
|
||||
ip: clientIp,
|
||||
});
|
||||
|
||||
const response: Record<string, unknown> = {
|
||||
@@ -1016,7 +1128,15 @@ router.post("/logout", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const payload = await authManager.verifyJWTToken(token);
|
||||
sessionId = payload?.sessionId;
|
||||
} catch (error) {}
|
||||
} catch (error) {
|
||||
authLogger.debug(
|
||||
"Token verification failed during logout (expected if token expired)",
|
||||
{
|
||||
operation: "logout_token_verify_failed",
|
||||
userId,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await authManager.logoutUser(userId, sessionId);
|
||||
@@ -1052,11 +1172,17 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
|
||||
return res.status(401).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
const hasPassword =
|
||||
user[0].password_hash && user[0].password_hash.trim() !== "";
|
||||
const hasOidc = user[0].is_oidc && user[0].oidc_identifier;
|
||||
const isDualAuth = hasPassword && hasOidc;
|
||||
|
||||
res.json({
|
||||
userId: user[0].id,
|
||||
username: user[0].username,
|
||||
is_admin: !!user[0].is_admin,
|
||||
is_oidc: !!user[0].is_oidc,
|
||||
is_dual_auth: isDualAuth,
|
||||
totp_enabled: !!user[0].totp_enabled,
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -1610,6 +1736,7 @@ router.get("/list", authenticateJWT, async (req, res) => {
|
||||
username: users.username,
|
||||
is_admin: users.is_admin,
|
||||
is_oidc: users.is_oidc,
|
||||
password_hash: users.password_hash,
|
||||
})
|
||||
.from(users);
|
||||
|
||||
@@ -1653,6 +1780,16 @@ router.post("/make-admin", authenticateJWT, async (req, res) => {
|
||||
.set({ is_admin: true })
|
||||
.where(eq(users.username, username));
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (saveError) {
|
||||
authLogger.error("Failed to persist admin promotion to disk", saveError, {
|
||||
operation: "make_admin_save_failed",
|
||||
username,
|
||||
});
|
||||
}
|
||||
|
||||
authLogger.success(
|
||||
`User ${username} made admin by ${adminUser[0].username}`,
|
||||
);
|
||||
@@ -1702,6 +1839,16 @@ router.post("/remove-admin", authenticateJWT, async (req, res) => {
|
||||
.set({ is_admin: false })
|
||||
.where(eq(users.username, username));
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (saveError) {
|
||||
authLogger.error("Failed to persist admin removal to disk", saveError, {
|
||||
operation: "remove_admin_save_failed",
|
||||
username,
|
||||
});
|
||||
}
|
||||
|
||||
authLogger.success(
|
||||
`Admin status removed from ${username} by ${adminUser[0].username}`,
|
||||
);
|
||||
@@ -2106,7 +2253,6 @@ router.delete("/delete-user", authenticateJWT, async (req, res) => {
|
||||
const targetUserId = targetUser[0].id;
|
||||
|
||||
try {
|
||||
// Delete all user-related data to avoid foreign key constraints
|
||||
await db
|
||||
.delete(sshCredentialUsage)
|
||||
.where(eq(sshCredentialUsage.userId, targetUserId));
|
||||
@@ -2426,4 +2572,295 @@ router.post("/sessions/revoke-all", authenticateJWT, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Link OIDC user to existing password account (merge accounts)
|
||||
// POST /users/link-oidc-to-password
|
||||
router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => {
|
||||
const adminUserId = (req as AuthenticatedRequest).userId;
|
||||
const { oidcUserId, targetUsername } = req.body;
|
||||
|
||||
if (!isNonEmptyString(oidcUserId) || !isNonEmptyString(targetUsername)) {
|
||||
return res.status(400).json({
|
||||
error: "OIDC user ID and target username are required",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const adminUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, adminUserId));
|
||||
if (!adminUser || adminUser.length === 0 || !adminUser[0].is_admin) {
|
||||
return res.status(403).json({ error: "Admin access required" });
|
||||
}
|
||||
|
||||
const oidcUserRecords = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, oidcUserId));
|
||||
if (!oidcUserRecords || oidcUserRecords.length === 0) {
|
||||
return res.status(404).json({ error: "OIDC user not found" });
|
||||
}
|
||||
|
||||
const oidcUser = oidcUserRecords[0];
|
||||
|
||||
if (!oidcUser.is_oidc) {
|
||||
return res.status(400).json({
|
||||
error: "Source user is not an OIDC user",
|
||||
});
|
||||
}
|
||||
|
||||
const targetUserRecords = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, targetUsername));
|
||||
if (!targetUserRecords || targetUserRecords.length === 0) {
|
||||
return res.status(404).json({ error: "Target password user not found" });
|
||||
}
|
||||
|
||||
const targetUser = targetUserRecords[0];
|
||||
|
||||
if (targetUser.is_oidc || !targetUser.password_hash) {
|
||||
return res.status(400).json({
|
||||
error: "Target user must be a password-based account",
|
||||
});
|
||||
}
|
||||
|
||||
if (targetUser.client_id && targetUser.oidc_identifier) {
|
||||
return res.status(400).json({
|
||||
error: "Target user already has OIDC authentication configured",
|
||||
});
|
||||
}
|
||||
|
||||
authLogger.info("Linking OIDC user to password account", {
|
||||
operation: "link_oidc_to_password",
|
||||
oidcUserId,
|
||||
oidcUsername: oidcUser.username,
|
||||
targetUserId: targetUser.id,
|
||||
targetUsername: targetUser.username,
|
||||
adminUserId,
|
||||
});
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
is_oidc: true,
|
||||
oidc_identifier: oidcUser.oidc_identifier,
|
||||
client_id: oidcUser.client_id,
|
||||
client_secret: oidcUser.client_secret,
|
||||
issuer_url: oidcUser.issuer_url,
|
||||
authorization_url: oidcUser.authorization_url,
|
||||
token_url: oidcUser.token_url,
|
||||
identifier_path: oidcUser.identifier_path,
|
||||
name_path: oidcUser.name_path,
|
||||
scopes: oidcUser.scopes || "openid email profile",
|
||||
})
|
||||
.where(eq(users.id, targetUser.id));
|
||||
|
||||
try {
|
||||
await authManager.convertToOIDCEncryption(targetUser.id);
|
||||
} catch (encryptionError) {
|
||||
authLogger.error(
|
||||
"Failed to convert encryption to OIDC during linking",
|
||||
encryptionError,
|
||||
{
|
||||
operation: "link_convert_encryption_failed",
|
||||
userId: targetUser.id,
|
||||
},
|
||||
);
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
is_oidc: false,
|
||||
oidc_identifier: null,
|
||||
client_id: "",
|
||||
client_secret: "",
|
||||
issuer_url: "",
|
||||
authorization_url: "",
|
||||
token_url: "",
|
||||
identifier_path: "",
|
||||
name_path: "",
|
||||
scopes: "openid email profile",
|
||||
})
|
||||
.where(eq(users.id, targetUser.id));
|
||||
|
||||
return res.status(500).json({
|
||||
error:
|
||||
"Failed to convert encryption for dual-auth. Please ensure the password account has encryption setup.",
|
||||
details:
|
||||
encryptionError instanceof Error
|
||||
? encryptionError.message
|
||||
: "Unknown error",
|
||||
});
|
||||
}
|
||||
|
||||
await authManager.revokeAllUserSessions(oidcUserId);
|
||||
authManager.logoutUser(oidcUserId);
|
||||
|
||||
await db
|
||||
.delete(recentActivity)
|
||||
.where(eq(recentActivity.userId, oidcUserId));
|
||||
|
||||
await db.delete(users).where(eq(users.id, oidcUserId));
|
||||
|
||||
db.$client
|
||||
.prepare("DELETE FROM settings WHERE key LIKE ?")
|
||||
.run(`user_%_${oidcUserId}`);
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (saveError) {
|
||||
authLogger.error("Failed to persist account linking to disk", saveError, {
|
||||
operation: "link_oidc_save_failed",
|
||||
oidcUserId,
|
||||
targetUserId: targetUser.id,
|
||||
});
|
||||
}
|
||||
|
||||
authLogger.success(
|
||||
`OIDC user ${oidcUser.username} linked to password account ${targetUser.username}`,
|
||||
{
|
||||
operation: "link_oidc_to_password_success",
|
||||
oidcUserId,
|
||||
oidcUsername: oidcUser.username,
|
||||
targetUserId: targetUser.id,
|
||||
targetUsername: targetUser.username,
|
||||
adminUserId,
|
||||
},
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `OIDC user ${oidcUser.username} has been linked to ${targetUser.username}. The password account can now use both password and OIDC login.`,
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to link OIDC user to password account", err, {
|
||||
operation: "link_oidc_to_password_failed",
|
||||
oidcUserId,
|
||||
targetUsername,
|
||||
adminUserId,
|
||||
});
|
||||
res.status(500).json({
|
||||
error: "Failed to link accounts",
|
||||
details: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Unlink OIDC from password account (admin only)
|
||||
// POST /users/unlink-oidc-from-password
|
||||
router.post("/unlink-oidc-from-password", authenticateJWT, async (req, res) => {
|
||||
const adminUserId = (req as AuthenticatedRequest).userId;
|
||||
const { userId } = req.body;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(400).json({
|
||||
error: "User ID is required",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const adminUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, adminUserId));
|
||||
|
||||
if (!adminUser || adminUser.length === 0 || !adminUser[0].is_admin) {
|
||||
authLogger.warn("Non-admin attempted to unlink OIDC from password", {
|
||||
operation: "unlink_oidc_unauthorized",
|
||||
adminUserId,
|
||||
targetUserId: userId,
|
||||
});
|
||||
return res.status(403).json({
|
||||
error: "Admin privileges required",
|
||||
});
|
||||
}
|
||||
|
||||
const targetUserRecords = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
if (!targetUserRecords || targetUserRecords.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
const targetUser = targetUserRecords[0];
|
||||
|
||||
if (!targetUser.is_oidc) {
|
||||
return res.status(400).json({
|
||||
error: "User does not have OIDC authentication enabled",
|
||||
});
|
||||
}
|
||||
|
||||
if (!targetUser.password_hash || targetUser.password_hash === "") {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"Cannot unlink OIDC from a user without password authentication. This would leave the user unable to login.",
|
||||
});
|
||||
}
|
||||
|
||||
authLogger.info("Unlinking OIDC from password account", {
|
||||
operation: "unlink_oidc_from_password_start",
|
||||
targetUserId: targetUser.id,
|
||||
targetUsername: targetUser.username,
|
||||
adminUserId,
|
||||
});
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
is_oidc: false,
|
||||
oidc_identifier: null,
|
||||
client_id: "",
|
||||
client_secret: "",
|
||||
issuer_url: "",
|
||||
authorization_url: "",
|
||||
token_url: "",
|
||||
identifier_path: "",
|
||||
name_path: "",
|
||||
scopes: "openid email profile",
|
||||
})
|
||||
.where(eq(users.id, targetUser.id));
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (saveError) {
|
||||
authLogger.error(
|
||||
"Failed to save database after unlinking OIDC",
|
||||
saveError,
|
||||
{
|
||||
operation: "unlink_oidc_save_failed",
|
||||
targetUserId: targetUser.id,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
authLogger.success("OIDC unlinked from password account successfully", {
|
||||
operation: "unlink_oidc_from_password_success",
|
||||
targetUserId: targetUser.id,
|
||||
targetUsername: targetUser.username,
|
||||
adminUserId,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `OIDC authentication has been removed from ${targetUser.username}. User can now only login with password.`,
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to unlink OIDC from password account", err, {
|
||||
operation: "unlink_oidc_from_password_failed",
|
||||
targetUserId: userId,
|
||||
adminUserId,
|
||||
});
|
||||
res.status(500).json({
|
||||
error: "Failed to unlink OIDC",
|
||||
details: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
108
src/backend/guacamole/guacamole-server.ts
Normal file
108
src/backend/guacamole/guacamole-server.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import GuacamoleLite from "guacamole-lite";
|
||||
import { parse as parseUrl } from "url";
|
||||
import { guacLogger } from "../utils/logger.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { GuacamoleTokenService } from "./token-service.js";
|
||||
import type { IncomingMessage } from "http";
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const tokenService = GuacamoleTokenService.getInstance();
|
||||
|
||||
// Configuration from environment
|
||||
const GUACD_HOST = process.env.GUACD_HOST || "localhost";
|
||||
const GUACD_PORT = parseInt(process.env.GUACD_PORT || "4822", 10);
|
||||
const GUAC_WS_PORT = 30007;
|
||||
|
||||
const websocketOptions = {
|
||||
port: GUAC_WS_PORT,
|
||||
};
|
||||
|
||||
const guacdOptions = {
|
||||
host: GUACD_HOST,
|
||||
port: GUACD_PORT,
|
||||
};
|
||||
|
||||
const clientOptions = {
|
||||
crypt: {
|
||||
cypher: "AES-256-CBC",
|
||||
key: tokenService.getEncryptionKey(),
|
||||
},
|
||||
log: {
|
||||
level: process.env.NODE_ENV === "production" ? "ERRORS" : "VERBOSE",
|
||||
stdLog: (...args: unknown[]) => {
|
||||
guacLogger.info(args.join(" "), { operation: "guac_log" });
|
||||
},
|
||||
errorLog: (...args: unknown[]) => {
|
||||
guacLogger.error(args.join(" "), { operation: "guac_error" });
|
||||
},
|
||||
},
|
||||
// Allow width, height, and dpi to be passed as query parameters
|
||||
// This allows the client to request the appropriate resolution at connection time
|
||||
allowedUnencryptedConnectionSettings: {
|
||||
rdp: ["width", "height", "dpi"],
|
||||
vnc: ["width", "height", "dpi"],
|
||||
telnet: ["width", "height"],
|
||||
},
|
||||
connectionDefaultSettings: {
|
||||
rdp: {
|
||||
security: "any",
|
||||
"ignore-cert": true,
|
||||
"enable-wallpaper": false,
|
||||
"enable-font-smoothing": true,
|
||||
"enable-desktop-composition": false,
|
||||
"disable-audio": false,
|
||||
"enable-drive": false,
|
||||
"resize-method": "display-update",
|
||||
width: 1280,
|
||||
height: 720,
|
||||
dpi: 96,
|
||||
},
|
||||
vnc: {
|
||||
"swap-red-blue": false,
|
||||
"cursor": "remote",
|
||||
width: 1280,
|
||||
height: 720,
|
||||
},
|
||||
telnet: {
|
||||
"terminal-type": "xterm-256color",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Create the guacamole-lite server
|
||||
const guacServer = new GuacamoleLite(
|
||||
websocketOptions,
|
||||
guacdOptions,
|
||||
clientOptions
|
||||
);
|
||||
|
||||
// Add authentication via processConnectionSettings callback
|
||||
guacServer.on("open", (clientConnection: { connectionSettings?: Record<string, unknown> }) => {
|
||||
guacLogger.info("Guacamole connection opened", {
|
||||
operation: "guac_connection_open",
|
||||
type: clientConnection.connectionSettings?.type,
|
||||
});
|
||||
});
|
||||
|
||||
guacServer.on("close", (clientConnection: { connectionSettings?: Record<string, unknown> }) => {
|
||||
guacLogger.info("Guacamole connection closed", {
|
||||
operation: "guac_connection_close",
|
||||
type: clientConnection.connectionSettings?.type,
|
||||
});
|
||||
});
|
||||
|
||||
guacServer.on("error", (clientConnection: { connectionSettings?: Record<string, unknown> }, error: Error) => {
|
||||
guacLogger.error("Guacamole connection error", error, {
|
||||
operation: "guac_connection_error",
|
||||
type: clientConnection.connectionSettings?.type,
|
||||
});
|
||||
});
|
||||
|
||||
guacLogger.info(`Guacamole WebSocket server started on port ${GUAC_WS_PORT}`, {
|
||||
operation: "guac_server_start",
|
||||
guacdHost: GUACD_HOST,
|
||||
guacdPort: GUACD_PORT,
|
||||
});
|
||||
|
||||
export { guacServer, tokenService };
|
||||
|
||||
159
src/backend/guacamole/routes.ts
Normal file
159
src/backend/guacamole/routes.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import express from "express";
|
||||
import { GuacamoleTokenService } from "./token-service.js";
|
||||
import { guacLogger } from "../utils/logger.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||
|
||||
const router = express.Router();
|
||||
const tokenService = GuacamoleTokenService.getInstance();
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
// Apply authentication middleware
|
||||
router.use(authManager.createAuthMiddleware());
|
||||
|
||||
/**
|
||||
* POST /guacamole/token
|
||||
* Generate an encrypted connection token for guacamole-lite
|
||||
*
|
||||
* Body: {
|
||||
* type: "rdp" | "vnc" | "telnet",
|
||||
* hostname: string,
|
||||
* port?: number,
|
||||
* username?: string,
|
||||
* password?: string,
|
||||
* domain?: string,
|
||||
* // Additional protocol-specific options
|
||||
* }
|
||||
*/
|
||||
router.post("/token", async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { type, hostname, port, username, password, domain, ...options } = req.body;
|
||||
|
||||
if (!type || !hostname) {
|
||||
return res.status(400).json({ error: "Missing required fields: type and hostname" });
|
||||
}
|
||||
|
||||
if (!["rdp", "vnc", "telnet"].includes(type)) {
|
||||
return res.status(400).json({ error: "Invalid connection type. Must be rdp, vnc, or telnet" });
|
||||
}
|
||||
|
||||
// Log received options for debugging
|
||||
guacLogger.info("Guacamole token request received", {
|
||||
operation: "guac_token_request",
|
||||
type,
|
||||
hostname,
|
||||
port,
|
||||
optionKeys: Object.keys(options),
|
||||
optionsCount: Object.keys(options).length,
|
||||
});
|
||||
|
||||
// Log specific option values for debugging
|
||||
if (Object.keys(options).length > 0) {
|
||||
guacLogger.info("Guacamole options received", {
|
||||
operation: "guac_token_options",
|
||||
options: JSON.stringify(options),
|
||||
});
|
||||
}
|
||||
|
||||
let token: string;
|
||||
|
||||
switch (type) {
|
||||
case "rdp":
|
||||
token = tokenService.createRdpToken(hostname, username || "", password || "", {
|
||||
port: port || 3389,
|
||||
domain,
|
||||
...options,
|
||||
});
|
||||
break;
|
||||
case "vnc":
|
||||
token = tokenService.createVncToken(hostname, password, {
|
||||
port: port || 5900,
|
||||
...options,
|
||||
});
|
||||
break;
|
||||
case "telnet":
|
||||
token = tokenService.createTelnetToken(hostname, username, password, {
|
||||
port: port || 23,
|
||||
...options,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
return res.status(400).json({ error: "Invalid connection type" });
|
||||
}
|
||||
|
||||
guacLogger.info("Generated guacamole connection token", {
|
||||
operation: "guac_token_generated",
|
||||
userId,
|
||||
type,
|
||||
hostname,
|
||||
});
|
||||
|
||||
res.json({ token });
|
||||
} catch (error) {
|
||||
guacLogger.error("Failed to generate guacamole token", error, {
|
||||
operation: "guac_token_error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to generate connection token" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /guacamole/status
|
||||
* Check if guacd is reachable
|
||||
*/
|
||||
router.get("/status", async (req, res) => {
|
||||
try {
|
||||
const guacdHost = process.env.GUACD_HOST || "localhost";
|
||||
const guacdPort = parseInt(process.env.GUACD_PORT || "4822", 10);
|
||||
|
||||
// Simple TCP check to see if guacd is responding
|
||||
const net = await import("net");
|
||||
|
||||
const checkConnection = (): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
const socket = new net.Socket();
|
||||
socket.setTimeout(3000);
|
||||
|
||||
socket.on("connect", () => {
|
||||
socket.destroy();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
socket.on("timeout", () => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
socket.on("error", () => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
socket.connect(guacdPort, guacdHost);
|
||||
});
|
||||
};
|
||||
|
||||
const isConnected = await checkConnection();
|
||||
|
||||
res.json({
|
||||
guacd: {
|
||||
host: guacdHost,
|
||||
port: guacdPort,
|
||||
status: isConnected ? "connected" : "disconnected",
|
||||
},
|
||||
websocket: {
|
||||
port: 30007,
|
||||
status: "running",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
guacLogger.error("Failed to check guacamole status", error, {
|
||||
operation: "guac_status_error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to check status" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
198
src/backend/guacamole/token-service.ts
Normal file
198
src/backend/guacamole/token-service.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import crypto from "crypto";
|
||||
import { guacLogger } from "../utils/logger.js";
|
||||
|
||||
export interface GuacamoleConnectionSettings {
|
||||
type: "rdp" | "vnc" | "telnet";
|
||||
settings: {
|
||||
hostname: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
domain?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
dpi?: number;
|
||||
// RDP specific
|
||||
security?: string;
|
||||
"ignore-cert"?: boolean;
|
||||
"enable-wallpaper"?: boolean;
|
||||
"enable-drive"?: boolean;
|
||||
"drive-path"?: string;
|
||||
"create-drive-path"?: boolean;
|
||||
// VNC specific
|
||||
"swap-red-blue"?: boolean;
|
||||
cursor?: string;
|
||||
// Telnet specific
|
||||
"terminal-type"?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GuacamoleToken {
|
||||
connection: GuacamoleConnectionSettings;
|
||||
}
|
||||
|
||||
const CIPHER = "aes-256-cbc";
|
||||
const KEY_LENGTH = 32; // 256 bits = 32 bytes
|
||||
|
||||
export class GuacamoleTokenService {
|
||||
private static instance: GuacamoleTokenService;
|
||||
private encryptionKey: Buffer;
|
||||
|
||||
private constructor() {
|
||||
// Use existing JWT secret or generate a dedicated key
|
||||
this.encryptionKey = this.initializeKey();
|
||||
}
|
||||
|
||||
static getInstance(): GuacamoleTokenService {
|
||||
if (!GuacamoleTokenService.instance) {
|
||||
GuacamoleTokenService.instance = new GuacamoleTokenService();
|
||||
}
|
||||
return GuacamoleTokenService.instance;
|
||||
}
|
||||
|
||||
private initializeKey(): Buffer {
|
||||
// Check for dedicated guacamole key first (must be 32 bytes / 64 hex chars)
|
||||
const existingKey = process.env.GUACAMOLE_ENCRYPTION_KEY;
|
||||
if (existingKey) {
|
||||
// If it's hex encoded (64 chars = 32 bytes)
|
||||
if (existingKey.length === 64 && /^[0-9a-fA-F]+$/.test(existingKey)) {
|
||||
return Buffer.from(existingKey, "hex");
|
||||
}
|
||||
// If it's already 32 bytes
|
||||
if (existingKey.length === KEY_LENGTH) {
|
||||
return Buffer.from(existingKey, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a deterministic key from JWT_SECRET if available
|
||||
const jwtSecret = process.env.JWT_SECRET;
|
||||
if (jwtSecret) {
|
||||
// SHA-256 produces exactly 32 bytes - perfect for AES-256
|
||||
return crypto.createHash("sha256").update(jwtSecret + "_guacamole").digest();
|
||||
}
|
||||
|
||||
// Last resort: generate random key (note: won't persist across restarts)
|
||||
guacLogger.warn("No persistent encryption key found, generating random key", {
|
||||
operation: "guac_key_generation",
|
||||
});
|
||||
return crypto.randomBytes(KEY_LENGTH);
|
||||
}
|
||||
|
||||
getEncryptionKey(): Buffer {
|
||||
return this.encryptionKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt connection settings into a token for guacamole-lite
|
||||
*/
|
||||
encryptToken(tokenObject: GuacamoleToken): string {
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv(CIPHER, this.encryptionKey, iv);
|
||||
|
||||
let encrypted = cipher.update(JSON.stringify(tokenObject), "utf8", "base64");
|
||||
encrypted += cipher.final("base64");
|
||||
|
||||
const data = {
|
||||
iv: iv.toString("base64"),
|
||||
value: encrypted,
|
||||
};
|
||||
|
||||
return Buffer.from(JSON.stringify(data)).toString("base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a token (for verification/debugging purposes)
|
||||
*/
|
||||
decryptToken(token: string): GuacamoleToken | null {
|
||||
try {
|
||||
const data = JSON.parse(Buffer.from(token, "base64").toString("utf8"));
|
||||
const iv = Buffer.from(data.iv, "base64");
|
||||
const decipher = crypto.createDecipheriv(CIPHER, this.encryptionKey, iv);
|
||||
|
||||
let decrypted = decipher.update(data.value, "base64", "utf8");
|
||||
decrypted += decipher.final("utf8");
|
||||
|
||||
return JSON.parse(decrypted) as GuacamoleToken;
|
||||
} catch (error) {
|
||||
guacLogger.error("Failed to decrypt guacamole token", error, {
|
||||
operation: "guac_token_decrypt_error",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a connection token for RDP
|
||||
* security options: "any", "nla", "nla-ext", "tls", "rdp", "vmconnect"
|
||||
*/
|
||||
createRdpToken(
|
||||
hostname: string,
|
||||
username: string,
|
||||
password: string,
|
||||
options: Partial<GuacamoleConnectionSettings["settings"]> = {}
|
||||
): string {
|
||||
const token: GuacamoleToken = {
|
||||
connection: {
|
||||
type: "rdp",
|
||||
settings: {
|
||||
hostname,
|
||||
username,
|
||||
password,
|
||||
port: 3389,
|
||||
security: "nla", // NLA is required for modern Windows (10/11, Server 2016+)
|
||||
"ignore-cert": true,
|
||||
...options,
|
||||
},
|
||||
},
|
||||
};
|
||||
return this.encryptToken(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a connection token for VNC
|
||||
*/
|
||||
createVncToken(
|
||||
hostname: string,
|
||||
password?: string,
|
||||
options: Partial<GuacamoleConnectionSettings["settings"]> = {}
|
||||
): string {
|
||||
const token: GuacamoleToken = {
|
||||
connection: {
|
||||
type: "vnc",
|
||||
settings: {
|
||||
hostname,
|
||||
password,
|
||||
port: 5900,
|
||||
...options,
|
||||
},
|
||||
},
|
||||
};
|
||||
return this.encryptToken(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a connection token for Telnet
|
||||
*/
|
||||
createTelnetToken(
|
||||
hostname: string,
|
||||
username?: string,
|
||||
password?: string,
|
||||
options: Partial<GuacamoleConnectionSettings["settings"]> = {}
|
||||
): string {
|
||||
const token: GuacamoleToken = {
|
||||
connection: {
|
||||
type: "telnet",
|
||||
settings: {
|
||||
hostname,
|
||||
username,
|
||||
password,
|
||||
port: 23,
|
||||
...options,
|
||||
},
|
||||
},
|
||||
};
|
||||
return this.encryptToken(token);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Client as SSHClient } from "ssh2";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { sshCredentials, sshData } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { fileLogger } from "../utils/logger.js";
|
||||
import { fileLogger, sshLogger } from "../utils/logger.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||
@@ -89,11 +89,179 @@ app.use(express.raw({ limit: "5gb", type: "application/octet-stream" }));
|
||||
const authManager = AuthManager.getInstance();
|
||||
app.use(authManager.createAuthMiddleware());
|
||||
|
||||
async function resolveJumpHost(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
): Promise<any | null> {
|
||||
try {
|
||||
const hosts = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (hosts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const host = hosts[0];
|
||||
|
||||
if (host.credentialId) {
|
||||
const credentials = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, host.credentialId as number),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
return {
|
||||
...host,
|
||||
password: credential.password,
|
||||
key:
|
||||
credential.private_key || credential.privateKey || credential.key,
|
||||
keyPassword: credential.key_password || credential.keyPassword,
|
||||
keyType: credential.key_type || credential.keyType,
|
||||
authType: credential.auth_type || credential.authType,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return host;
|
||||
} catch (error) {
|
||||
fileLogger.error("Failed to resolve jump host", error, {
|
||||
operation: "resolve_jump_host",
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function createJumpHostChain(
|
||||
jumpHosts: Array<{ hostId: number }>,
|
||||
userId: string,
|
||||
): Promise<SSHClient | null> {
|
||||
if (!jumpHosts || jumpHosts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let currentClient: SSHClient | null = null;
|
||||
const clients: SSHClient[] = [];
|
||||
|
||||
try {
|
||||
for (let i = 0; i < jumpHosts.length; i++) {
|
||||
const jumpHostConfig = await resolveJumpHost(jumpHosts[i].hostId, userId);
|
||||
|
||||
if (!jumpHostConfig) {
|
||||
fileLogger.error(`Jump host ${i + 1} not found`, undefined, {
|
||||
operation: "jump_host_chain",
|
||||
hostId: jumpHosts[i].hostId,
|
||||
});
|
||||
clients.forEach((c) => c.end());
|
||||
return null;
|
||||
}
|
||||
|
||||
const jumpClient = new SSHClient();
|
||||
clients.push(jumpClient);
|
||||
|
||||
const connected = await new Promise<boolean>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
resolve(false);
|
||||
}, 30000);
|
||||
|
||||
jumpClient.on("ready", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
jumpClient.on("error", (err) => {
|
||||
clearTimeout(timeout);
|
||||
fileLogger.error(`Jump host ${i + 1} connection failed`, err, {
|
||||
operation: "jump_host_connect",
|
||||
hostId: jumpHostConfig.id,
|
||||
ip: jumpHostConfig.ip,
|
||||
});
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
const connectConfig: any = {
|
||||
host: jumpHostConfig.ip,
|
||||
port: jumpHostConfig.port || 22,
|
||||
username: jumpHostConfig.username,
|
||||
tryKeyboard: true,
|
||||
readyTimeout: 30000,
|
||||
};
|
||||
|
||||
if (jumpHostConfig.authType === "password" && jumpHostConfig.password) {
|
||||
connectConfig.password = jumpHostConfig.password;
|
||||
} else if (jumpHostConfig.authType === "key" && jumpHostConfig.key) {
|
||||
const cleanKey = jumpHostConfig.key
|
||||
.trim()
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n");
|
||||
connectConfig.privateKey = Buffer.from(cleanKey, "utf8");
|
||||
if (jumpHostConfig.keyPassword) {
|
||||
connectConfig.passphrase = jumpHostConfig.keyPassword;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentClient) {
|
||||
currentClient.forwardOut(
|
||||
"127.0.0.1",
|
||||
0,
|
||||
jumpHostConfig.ip,
|
||||
jumpHostConfig.port || 22,
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(timeout);
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
connectConfig.sock = stream;
|
||||
jumpClient.connect(connectConfig);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
jumpClient.connect(connectConfig);
|
||||
}
|
||||
});
|
||||
|
||||
if (!connected) {
|
||||
clients.forEach((c) => c.end());
|
||||
return null;
|
||||
}
|
||||
|
||||
currentClient = jumpClient;
|
||||
}
|
||||
|
||||
return currentClient;
|
||||
} catch (error) {
|
||||
fileLogger.error("Failed to create jump host chain", error, {
|
||||
operation: "jump_host_chain",
|
||||
});
|
||||
clients.forEach((c) => c.end());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface SSHSession {
|
||||
client: SSHClient;
|
||||
isConnected: boolean;
|
||||
lastActive: number;
|
||||
timeout?: NodeJS.Timeout;
|
||||
activeOperations: number;
|
||||
}
|
||||
|
||||
interface PendingTOTPSession {
|
||||
@@ -118,9 +286,22 @@ const pendingTOTPSessions: Record<string, PendingTOTPSession> = {};
|
||||
function cleanupSession(sessionId: string) {
|
||||
const session = sshSessions[sessionId];
|
||||
if (session) {
|
||||
if (session.activeOperations > 0) {
|
||||
fileLogger.warn(
|
||||
`Deferring session cleanup for ${sessionId} - ${session.activeOperations} active operations`,
|
||||
{
|
||||
operation: "cleanup_deferred",
|
||||
sessionId,
|
||||
activeOperations: session.activeOperations,
|
||||
},
|
||||
);
|
||||
scheduleSessionCleanup(sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
session.client.end();
|
||||
} catch {}
|
||||
} catch (error) {}
|
||||
clearTimeout(session.timeout);
|
||||
delete sshSessions[sessionId];
|
||||
}
|
||||
@@ -174,6 +355,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
credentialId,
|
||||
userProvidedPassword,
|
||||
forceKeyboardInteractive,
|
||||
jumpHosts,
|
||||
} = req.body;
|
||||
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
@@ -393,6 +575,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
client,
|
||||
isConnected: true,
|
||||
lastActive: Date.now(),
|
||||
activeOperations: 0,
|
||||
};
|
||||
scheduleSessionCleanup(sessionId);
|
||||
res.json({ status: "success", message: "SSH connection established" });
|
||||
@@ -625,7 +808,52 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
},
|
||||
);
|
||||
|
||||
client.connect(config);
|
||||
if (jumpHosts && jumpHosts.length > 0 && userId) {
|
||||
try {
|
||||
const jumpClient = await createJumpHostChain(jumpHosts, userId);
|
||||
|
||||
if (!jumpClient) {
|
||||
fileLogger.error("Failed to establish jump host chain", {
|
||||
operation: "file_jump_chain",
|
||||
sessionId,
|
||||
hostId,
|
||||
});
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Failed to connect through jump hosts" });
|
||||
}
|
||||
|
||||
jumpClient.forwardOut("127.0.0.1", 0, ip, port, (err, stream) => {
|
||||
if (err) {
|
||||
fileLogger.error("Failed to forward through jump host", err, {
|
||||
operation: "file_jump_forward",
|
||||
sessionId,
|
||||
hostId,
|
||||
ip,
|
||||
port,
|
||||
});
|
||||
jumpClient.end();
|
||||
return res.status(500).json({
|
||||
error: "Failed to forward through jump host: " + err.message,
|
||||
});
|
||||
}
|
||||
|
||||
config.sock = stream;
|
||||
client.connect(config);
|
||||
});
|
||||
} catch (error) {
|
||||
fileLogger.error("Jump host error", error, {
|
||||
operation: "file_jump_host",
|
||||
sessionId,
|
||||
hostId,
|
||||
});
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Failed to connect through jump hosts" });
|
||||
}
|
||||
} else {
|
||||
client.connect(config);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
|
||||
@@ -663,7 +891,9 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
|
||||
delete pendingTOTPSessions[sessionId];
|
||||
try {
|
||||
session.client.end();
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
sshLogger.debug("Operation failed, continuing", { error });
|
||||
}
|
||||
fileLogger.warn("TOTP session timeout before code submission", {
|
||||
operation: "file_totp_verify",
|
||||
sessionId,
|
||||
@@ -700,6 +930,7 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
|
||||
client: session.client,
|
||||
isConnected: true,
|
||||
lastActive: Date.now(),
|
||||
activeOperations: 0,
|
||||
};
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
@@ -843,10 +1074,12 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
sshConn.activeOperations++;
|
||||
|
||||
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
|
||||
sshConn.client.exec(`ls -la '${escapedPath}'`, (err, stream) => {
|
||||
sshConn.client.exec(`command ls -la '${escapedPath}'`, (err, stream) => {
|
||||
if (err) {
|
||||
sshConn.activeOperations--;
|
||||
fileLogger.error("SSH listFiles error:", err);
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
@@ -863,6 +1096,7 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
sshConn.activeOperations--;
|
||||
if (code !== 0) {
|
||||
fileLogger.error(
|
||||
`SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
|
||||
@@ -2486,6 +2720,516 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => {
|
||||
const { sessionId, path, permissions } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
|
||||
if (!sshConn || !sshConn.isConnected) {
|
||||
fileLogger.error(
|
||||
"SSH connection not found or not connected for changePermissions",
|
||||
{
|
||||
operation: "change_permissions",
|
||||
sessionId,
|
||||
hasConnection: !!sshConn,
|
||||
isConnected: sshConn?.isConnected,
|
||||
},
|
||||
);
|
||||
return res.status(400).json({ error: "SSH connection not available" });
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
return res.status(400).json({ error: "File path is required" });
|
||||
}
|
||||
|
||||
if (!permissions || !/^\d{3,4}$/.test(permissions)) {
|
||||
return res.status(400).json({
|
||||
error: "Valid permissions required (e.g., 755, 644)",
|
||||
});
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
const octalPerms = permissions.slice(-3);
|
||||
const escapedPath = path.replace(/'/g, "'\"'\"'");
|
||||
const command = `chmod ${octalPerms} '${escapedPath}' && echo "SUCCESS"`;
|
||||
|
||||
fileLogger.info("Changing file permissions", {
|
||||
operation: "change_permissions",
|
||||
sessionId,
|
||||
path,
|
||||
permissions: octalPerms,
|
||||
});
|
||||
|
||||
const commandTimeout = setTimeout(() => {
|
||||
if (!res.headersSent) {
|
||||
fileLogger.error("changePermissions command timeout", {
|
||||
operation: "change_permissions",
|
||||
sessionId,
|
||||
path,
|
||||
permissions: octalPerms,
|
||||
});
|
||||
res.status(408).json({
|
||||
error: "Permission change timed out. SSH connection may be unstable.",
|
||||
});
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
sshConn.client.exec(command, (err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(commandTimeout);
|
||||
fileLogger.error("SSH changePermissions exec error:", err, {
|
||||
operation: "change_permissions",
|
||||
sessionId,
|
||||
path,
|
||||
permissions: octalPerms,
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({ error: "Failed to change permissions" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let outputData = "";
|
||||
let errorOutput = "";
|
||||
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
outputData += chunk.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (data: Buffer) => {
|
||||
errorOutput += data.toString();
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
clearTimeout(commandTimeout);
|
||||
|
||||
if (outputData.includes("SUCCESS")) {
|
||||
fileLogger.success("File permissions changed successfully", {
|
||||
operation: "change_permissions",
|
||||
sessionId,
|
||||
path,
|
||||
permissions: octalPerms,
|
||||
});
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Permissions changed successfully",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (code !== 0) {
|
||||
fileLogger.error("chmod command failed", {
|
||||
operation: "change_permissions",
|
||||
sessionId,
|
||||
path,
|
||||
permissions: octalPerms,
|
||||
exitCode: code,
|
||||
error: errorOutput,
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({
|
||||
error: errorOutput || "Failed to change permissions",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
fileLogger.success("File permissions changed successfully", {
|
||||
operation: "change_permissions",
|
||||
sessionId,
|
||||
path,
|
||||
permissions: octalPerms,
|
||||
});
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Permissions changed successfully",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("error", (streamErr) => {
|
||||
clearTimeout(commandTimeout);
|
||||
fileLogger.error("SSH changePermissions stream error:", streamErr, {
|
||||
operation: "change_permissions",
|
||||
sessionId,
|
||||
path,
|
||||
permissions: octalPerms,
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Stream error while changing permissions" });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Route: Extract archive file (requires JWT)
|
||||
// POST /ssh/file_manager/ssh/extractArchive
|
||||
app.post("/ssh/file_manager/ssh/extractArchive", async (req, res) => {
|
||||
const { sessionId, archivePath, extractPath } = req.body;
|
||||
|
||||
if (!sessionId || !archivePath) {
|
||||
return res.status(400).json({ error: "Missing required parameters" });
|
||||
}
|
||||
|
||||
const session = sshSessions[sessionId];
|
||||
if (!session || !session.isConnected) {
|
||||
return res.status(400).json({ error: "SSH session not connected" });
|
||||
}
|
||||
|
||||
session.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
const fileName = archivePath.split("/").pop() || "";
|
||||
const fileExt = fileName.toLowerCase();
|
||||
|
||||
let extractCommand = "";
|
||||
const targetPath =
|
||||
extractPath || archivePath.substring(0, archivePath.lastIndexOf("/"));
|
||||
|
||||
if (fileExt.endsWith(".tar.gz") || fileExt.endsWith(".tgz")) {
|
||||
extractCommand = `tar -xzf "${archivePath}" -C "${targetPath}"`;
|
||||
} else if (fileExt.endsWith(".tar.bz2") || fileExt.endsWith(".tbz2")) {
|
||||
extractCommand = `tar -xjf "${archivePath}" -C "${targetPath}"`;
|
||||
} else if (fileExt.endsWith(".tar.xz")) {
|
||||
extractCommand = `tar -xJf "${archivePath}" -C "${targetPath}"`;
|
||||
} else if (fileExt.endsWith(".tar")) {
|
||||
extractCommand = `tar -xf "${archivePath}" -C "${targetPath}"`;
|
||||
} else if (fileExt.endsWith(".zip")) {
|
||||
extractCommand = `unzip -o "${archivePath}" -d "${targetPath}"`;
|
||||
} else if (fileExt.endsWith(".gz") && !fileExt.endsWith(".tar.gz")) {
|
||||
extractCommand = `gunzip -c "${archivePath}" > "${archivePath.replace(/\.gz$/, "")}"`;
|
||||
} else if (fileExt.endsWith(".bz2") && !fileExt.endsWith(".tar.bz2")) {
|
||||
extractCommand = `bunzip2 -k "${archivePath}"`;
|
||||
} else if (fileExt.endsWith(".xz") && !fileExt.endsWith(".tar.xz")) {
|
||||
extractCommand = `unxz -k "${archivePath}"`;
|
||||
} else if (fileExt.endsWith(".7z")) {
|
||||
extractCommand = `7z x "${archivePath}" -o"${targetPath}"`;
|
||||
} else if (fileExt.endsWith(".rar")) {
|
||||
extractCommand = `unrar x "${archivePath}" "${targetPath}/"`;
|
||||
} else {
|
||||
return res.status(400).json({ error: "Unsupported archive format" });
|
||||
}
|
||||
|
||||
fileLogger.info("Extracting archive", {
|
||||
operation: "extract_archive",
|
||||
sessionId,
|
||||
archivePath,
|
||||
extractPath: targetPath,
|
||||
command: extractCommand,
|
||||
});
|
||||
|
||||
session.client.exec(extractCommand, (err, stream) => {
|
||||
if (err) {
|
||||
fileLogger.error("SSH exec error during extract:", err, {
|
||||
operation: "extract_archive",
|
||||
sessionId,
|
||||
archivePath,
|
||||
});
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Failed to execute extract command" });
|
||||
}
|
||||
|
||||
let errorOutput = "";
|
||||
|
||||
stream.on("data", (data: Buffer) => {
|
||||
fileLogger.debug("Extract stdout", {
|
||||
operation: "extract_archive",
|
||||
sessionId,
|
||||
output: data.toString(),
|
||||
});
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (data: Buffer) => {
|
||||
errorOutput += data.toString();
|
||||
fileLogger.debug("Extract stderr", {
|
||||
operation: "extract_archive",
|
||||
sessionId,
|
||||
error: data.toString(),
|
||||
});
|
||||
});
|
||||
|
||||
stream.on("close", (code: number) => {
|
||||
if (code !== 0) {
|
||||
fileLogger.error("Extract command failed", {
|
||||
operation: "extract_archive",
|
||||
sessionId,
|
||||
archivePath,
|
||||
exitCode: code,
|
||||
error: errorOutput,
|
||||
});
|
||||
|
||||
let friendlyError = errorOutput || "Failed to extract archive";
|
||||
if (
|
||||
errorOutput.includes("command not found") ||
|
||||
errorOutput.includes("not found")
|
||||
) {
|
||||
let missingCmd = "";
|
||||
let installHint = "";
|
||||
|
||||
if (fileExt.endsWith(".zip")) {
|
||||
missingCmd = "unzip";
|
||||
installHint =
|
||||
"apt install unzip / yum install unzip / brew install unzip";
|
||||
} else if (
|
||||
fileExt.endsWith(".tar.gz") ||
|
||||
fileExt.endsWith(".tgz") ||
|
||||
fileExt.endsWith(".tar.bz2") ||
|
||||
fileExt.endsWith(".tbz2") ||
|
||||
fileExt.endsWith(".tar.xz") ||
|
||||
fileExt.endsWith(".tar")
|
||||
) {
|
||||
missingCmd = "tar";
|
||||
installHint = "Usually pre-installed on Linux/Unix systems";
|
||||
} else if (fileExt.endsWith(".gz")) {
|
||||
missingCmd = "gunzip";
|
||||
installHint =
|
||||
"apt install gzip / yum install gzip / Usually pre-installed";
|
||||
} else if (fileExt.endsWith(".bz2")) {
|
||||
missingCmd = "bunzip2";
|
||||
installHint =
|
||||
"apt install bzip2 / yum install bzip2 / brew install bzip2";
|
||||
} else if (fileExt.endsWith(".xz")) {
|
||||
missingCmd = "unxz";
|
||||
installHint =
|
||||
"apt install xz-utils / yum install xz / brew install xz";
|
||||
} else if (fileExt.endsWith(".7z")) {
|
||||
missingCmd = "7z";
|
||||
installHint =
|
||||
"apt install p7zip-full / yum install p7zip / brew install p7zip";
|
||||
} else if (fileExt.endsWith(".rar")) {
|
||||
missingCmd = "unrar";
|
||||
installHint =
|
||||
"apt install unrar / yum install unrar / brew install unrar";
|
||||
}
|
||||
|
||||
if (missingCmd) {
|
||||
friendlyError = `Command '${missingCmd}' not found on remote server. Please install it first: ${installHint}`;
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(500).json({ error: friendlyError });
|
||||
}
|
||||
|
||||
fileLogger.success("Archive extracted successfully", {
|
||||
operation: "extract_archive",
|
||||
sessionId,
|
||||
archivePath,
|
||||
extractPath: targetPath,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Archive extracted successfully",
|
||||
extractPath: targetPath,
|
||||
});
|
||||
});
|
||||
|
||||
stream.on("error", (streamErr) => {
|
||||
fileLogger.error("SSH extractArchive stream error:", streamErr, {
|
||||
operation: "extract_archive",
|
||||
sessionId,
|
||||
archivePath,
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Stream error while extracting archive" });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Route: Compress files/folders (requires JWT)
|
||||
// POST /ssh/file_manager/ssh/compressFiles
|
||||
app.post("/ssh/file_manager/ssh/compressFiles", async (req, res) => {
|
||||
const { sessionId, paths, archiveName, format } = req.body;
|
||||
|
||||
if (
|
||||
!sessionId ||
|
||||
!paths ||
|
||||
!Array.isArray(paths) ||
|
||||
paths.length === 0 ||
|
||||
!archiveName
|
||||
) {
|
||||
return res.status(400).json({ error: "Missing required parameters" });
|
||||
}
|
||||
|
||||
const session = sshSessions[sessionId];
|
||||
if (!session || !session.isConnected) {
|
||||
return res.status(400).json({ error: "SSH session not connected" });
|
||||
}
|
||||
|
||||
session.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
const compressionFormat = format || "zip";
|
||||
let compressCommand = "";
|
||||
|
||||
const firstPath = paths[0];
|
||||
const workingDir = firstPath.substring(0, firstPath.lastIndexOf("/")) || "/";
|
||||
|
||||
const fileNames = paths
|
||||
.map((p) => {
|
||||
const name = p.split("/").pop();
|
||||
return `"${name}"`;
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
let archivePath = "";
|
||||
if (archiveName.includes("/")) {
|
||||
archivePath = archiveName;
|
||||
} else {
|
||||
archivePath = workingDir.endsWith("/")
|
||||
? `${workingDir}${archiveName}`
|
||||
: `${workingDir}/${archiveName}`;
|
||||
}
|
||||
|
||||
if (compressionFormat === "zip") {
|
||||
compressCommand = `cd "${workingDir}" && zip -r "${archivePath}" ${fileNames}`;
|
||||
} else if (compressionFormat === "tar.gz" || compressionFormat === "tgz") {
|
||||
compressCommand = `cd "${workingDir}" && tar -czf "${archivePath}" ${fileNames}`;
|
||||
} else if (compressionFormat === "tar.bz2" || compressionFormat === "tbz2") {
|
||||
compressCommand = `cd "${workingDir}" && tar -cjf "${archivePath}" ${fileNames}`;
|
||||
} else if (compressionFormat === "tar.xz") {
|
||||
compressCommand = `cd "${workingDir}" && tar -cJf "${archivePath}" ${fileNames}`;
|
||||
} else if (compressionFormat === "tar") {
|
||||
compressCommand = `cd "${workingDir}" && tar -cf "${archivePath}" ${fileNames}`;
|
||||
} else if (compressionFormat === "7z") {
|
||||
compressCommand = `cd "${workingDir}" && 7z a "${archivePath}" ${fileNames}`;
|
||||
} else {
|
||||
return res.status(400).json({ error: "Unsupported compression format" });
|
||||
}
|
||||
|
||||
fileLogger.info("Compressing files", {
|
||||
operation: "compress_files",
|
||||
sessionId,
|
||||
paths,
|
||||
archivePath,
|
||||
format: compressionFormat,
|
||||
command: compressCommand,
|
||||
});
|
||||
|
||||
session.client.exec(compressCommand, (err, stream) => {
|
||||
if (err) {
|
||||
fileLogger.error("SSH exec error during compress:", err, {
|
||||
operation: "compress_files",
|
||||
sessionId,
|
||||
paths,
|
||||
});
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Failed to execute compress command" });
|
||||
}
|
||||
|
||||
let errorOutput = "";
|
||||
|
||||
stream.on("data", (data: Buffer) => {
|
||||
fileLogger.debug("Compress stdout", {
|
||||
operation: "compress_files",
|
||||
sessionId,
|
||||
output: data.toString(),
|
||||
});
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (data: Buffer) => {
|
||||
errorOutput += data.toString();
|
||||
fileLogger.debug("Compress stderr", {
|
||||
operation: "compress_files",
|
||||
sessionId,
|
||||
error: data.toString(),
|
||||
});
|
||||
});
|
||||
|
||||
stream.on("close", (code: number) => {
|
||||
if (code !== 0) {
|
||||
fileLogger.error("Compress command failed", {
|
||||
operation: "compress_files",
|
||||
sessionId,
|
||||
paths,
|
||||
archivePath,
|
||||
exitCode: code,
|
||||
error: errorOutput,
|
||||
});
|
||||
|
||||
let friendlyError = errorOutput || "Failed to compress files";
|
||||
if (
|
||||
errorOutput.includes("command not found") ||
|
||||
errorOutput.includes("not found")
|
||||
) {
|
||||
const commandMap: Record<string, { cmd: string; install: string }> = {
|
||||
zip: {
|
||||
cmd: "zip",
|
||||
install: "apt install zip / yum install zip / brew install zip",
|
||||
},
|
||||
"tar.gz": {
|
||||
cmd: "tar",
|
||||
install: "Usually pre-installed on Linux/Unix systems",
|
||||
},
|
||||
"tar.bz2": {
|
||||
cmd: "tar",
|
||||
install: "Usually pre-installed on Linux/Unix systems",
|
||||
},
|
||||
"tar.xz": {
|
||||
cmd: "tar",
|
||||
install: "Usually pre-installed on Linux/Unix systems",
|
||||
},
|
||||
tar: {
|
||||
cmd: "tar",
|
||||
install: "Usually pre-installed on Linux/Unix systems",
|
||||
},
|
||||
"7z": {
|
||||
cmd: "7z",
|
||||
install:
|
||||
"apt install p7zip-full / yum install p7zip / brew install p7zip",
|
||||
},
|
||||
};
|
||||
|
||||
const info = commandMap[compressionFormat];
|
||||
if (info) {
|
||||
friendlyError = `Command '${info.cmd}' not found on remote server. Please install it first: ${info.install}`;
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(500).json({ error: friendlyError });
|
||||
}
|
||||
|
||||
fileLogger.success("Files compressed successfully", {
|
||||
operation: "compress_files",
|
||||
sessionId,
|
||||
paths,
|
||||
archivePath,
|
||||
format: compressionFormat,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Files compressed successfully",
|
||||
archivePath: archivePath,
|
||||
});
|
||||
});
|
||||
|
||||
stream.on("error", (streamErr) => {
|
||||
fileLogger.error("SSH compressFiles stream error:", streamErr, {
|
||||
operation: "compress_files",
|
||||
sessionId,
|
||||
paths,
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: "Stream error while compressing files" });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
Object.keys(sshSessions).forEach(cleanupSession);
|
||||
process.exit(0);
|
||||
|
||||
@@ -6,10 +6,185 @@ import { Client, type ConnectConfig } from "ssh2";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { sshData, sshCredentials } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { statsLogger } from "../utils/logger.js";
|
||||
import { statsLogger, sshLogger } from "../utils/logger.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||
import { collectCpuMetrics } from "./widgets/cpu-collector.js";
|
||||
import { collectMemoryMetrics } from "./widgets/memory-collector.js";
|
||||
import { collectDiskMetrics } from "./widgets/disk-collector.js";
|
||||
import { collectNetworkMetrics } from "./widgets/network-collector.js";
|
||||
import { collectUptimeMetrics } from "./widgets/uptime-collector.js";
|
||||
import { collectProcessesMetrics } from "./widgets/processes-collector.js";
|
||||
import { collectSystemMetrics } from "./widgets/system-collector.js";
|
||||
import { collectLoginStats } from "./widgets/login-stats-collector.js";
|
||||
|
||||
async function resolveJumpHost(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
): Promise<any | null> {
|
||||
try {
|
||||
const hosts = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (hosts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const host = hosts[0];
|
||||
|
||||
if (host.credentialId) {
|
||||
const credentials = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, host.credentialId as number),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
return {
|
||||
...host,
|
||||
password: credential.password,
|
||||
key:
|
||||
credential.private_key || credential.privateKey || credential.key,
|
||||
keyPassword: credential.key_password || credential.keyPassword,
|
||||
keyType: credential.key_type || credential.keyType,
|
||||
authType: credential.auth_type || credential.authType,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return host;
|
||||
} catch (error) {
|
||||
statsLogger.error("Failed to resolve jump host", error, {
|
||||
operation: "resolve_jump_host",
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function createJumpHostChain(
|
||||
jumpHosts: Array<{ hostId: number }>,
|
||||
userId: string,
|
||||
): Promise<Client | null> {
|
||||
if (!jumpHosts || jumpHosts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let currentClient: Client | null = null;
|
||||
const clients: Client[] = [];
|
||||
|
||||
try {
|
||||
for (let i = 0; i < jumpHosts.length; i++) {
|
||||
const jumpHostConfig = await resolveJumpHost(jumpHosts[i].hostId, userId);
|
||||
|
||||
if (!jumpHostConfig) {
|
||||
statsLogger.error(`Jump host ${i + 1} not found`, undefined, {
|
||||
operation: "jump_host_chain",
|
||||
hostId: jumpHosts[i].hostId,
|
||||
});
|
||||
clients.forEach((c) => c.end());
|
||||
return null;
|
||||
}
|
||||
|
||||
const jumpClient = new Client();
|
||||
clients.push(jumpClient);
|
||||
|
||||
const connected = await new Promise<boolean>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
resolve(false);
|
||||
}, 30000);
|
||||
|
||||
jumpClient.on("ready", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
jumpClient.on("error", (err) => {
|
||||
clearTimeout(timeout);
|
||||
statsLogger.error(`Jump host ${i + 1} connection failed`, err, {
|
||||
operation: "jump_host_connect",
|
||||
hostId: jumpHostConfig.id,
|
||||
ip: jumpHostConfig.ip,
|
||||
});
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
const connectConfig: any = {
|
||||
host: jumpHostConfig.ip,
|
||||
port: jumpHostConfig.port || 22,
|
||||
username: jumpHostConfig.username,
|
||||
tryKeyboard: true,
|
||||
readyTimeout: 30000,
|
||||
};
|
||||
|
||||
if (jumpHostConfig.authType === "password" && jumpHostConfig.password) {
|
||||
connectConfig.password = jumpHostConfig.password;
|
||||
} else if (jumpHostConfig.authType === "key" && jumpHostConfig.key) {
|
||||
const cleanKey = jumpHostConfig.key
|
||||
.trim()
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n");
|
||||
connectConfig.privateKey = Buffer.from(cleanKey, "utf8");
|
||||
if (jumpHostConfig.keyPassword) {
|
||||
connectConfig.passphrase = jumpHostConfig.keyPassword;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentClient) {
|
||||
currentClient.forwardOut(
|
||||
"127.0.0.1",
|
||||
0,
|
||||
jumpHostConfig.ip,
|
||||
jumpHostConfig.port || 22,
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(timeout);
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
connectConfig.sock = stream;
|
||||
jumpClient.connect(connectConfig);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
jumpClient.connect(connectConfig);
|
||||
}
|
||||
});
|
||||
|
||||
if (!connected) {
|
||||
clients.forEach((c) => c.end());
|
||||
return null;
|
||||
}
|
||||
|
||||
currentClient = jumpClient;
|
||||
}
|
||||
|
||||
return currentClient;
|
||||
} catch (error) {
|
||||
statsLogger.error("Failed to create jump host chain", error, {
|
||||
operation: "jump_host_chain",
|
||||
});
|
||||
clients.forEach((c) => c.end());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface PooledConnection {
|
||||
client: Client;
|
||||
@@ -79,7 +254,7 @@ class SSHConnectionPool {
|
||||
private async createConnection(
|
||||
host: SSHHostWithCredentials,
|
||||
): Promise<Client> {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const client = new Client();
|
||||
const timeout = setTimeout(() => {
|
||||
client.end();
|
||||
@@ -120,7 +295,12 @@ class SSHConnectionPool {
|
||||
),
|
||||
);
|
||||
} else if (host.password) {
|
||||
const responses = prompts.map(() => host.password || "");
|
||||
const responses = prompts.map((p) => {
|
||||
if (/password/i.test(p.prompt)) {
|
||||
return host.password || "";
|
||||
}
|
||||
return "";
|
||||
});
|
||||
finish(responses);
|
||||
} else {
|
||||
finish(prompts.map(() => ""));
|
||||
@@ -129,7 +309,44 @@ class SSHConnectionPool {
|
||||
);
|
||||
|
||||
try {
|
||||
client.connect(buildSshConfig(host));
|
||||
const config = buildSshConfig(host);
|
||||
|
||||
if (host.jumpHosts && host.jumpHosts.length > 0 && host.userId) {
|
||||
const jumpClient = await createJumpHostChain(
|
||||
host.jumpHosts,
|
||||
host.userId,
|
||||
);
|
||||
|
||||
if (!jumpClient) {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error("Failed to establish jump host chain"));
|
||||
return;
|
||||
}
|
||||
|
||||
jumpClient.forwardOut(
|
||||
"127.0.0.1",
|
||||
0,
|
||||
host.ip,
|
||||
host.port,
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(timeout);
|
||||
jumpClient.end();
|
||||
reject(
|
||||
new Error(
|
||||
"Failed to forward through jump host: " + err.message,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
config.sock = stream;
|
||||
client.connect(config);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
client.connect(config);
|
||||
}
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
@@ -156,7 +373,7 @@ class SSHConnectionPool {
|
||||
if (!conn.inUse && now - conn.lastUsed > maxAge) {
|
||||
try {
|
||||
conn.client.end();
|
||||
} catch {}
|
||||
} catch (error) {}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -176,7 +393,7 @@ class SSHConnectionPool {
|
||||
for (const conn of connections) {
|
||||
try {
|
||||
conn.client.end();
|
||||
} catch {}
|
||||
} catch (error) {}
|
||||
}
|
||||
}
|
||||
this.connections.clear();
|
||||
@@ -214,7 +431,7 @@ class RequestQueue {
|
||||
if (request) {
|
||||
try {
|
||||
await request();
|
||||
} catch {}
|
||||
} catch (error) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,7 +599,8 @@ interface SSHHostWithCredentials {
|
||||
enableFileManager: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: unknown[];
|
||||
statsConfig?: string;
|
||||
jumpHosts?: Array<{ hostId: number }>;
|
||||
statsConfig?: string | StatsConfig;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
userId: string;
|
||||
@@ -427,34 +645,64 @@ class PollingManager {
|
||||
}
|
||||
>();
|
||||
|
||||
parseStatsConfig(statsConfigStr?: string): StatsConfig {
|
||||
parseStatsConfig(statsConfigStr?: string | StatsConfig): StatsConfig {
|
||||
if (!statsConfigStr) {
|
||||
return DEFAULT_STATS_CONFIG;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(statsConfigStr);
|
||||
return { ...DEFAULT_STATS_CONFIG, ...parsed };
|
||||
} catch (error) {
|
||||
statsLogger.warn(
|
||||
`Failed to parse statsConfig: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
return DEFAULT_STATS_CONFIG;
|
||||
|
||||
let parsed: StatsConfig;
|
||||
|
||||
if (typeof statsConfigStr === "object") {
|
||||
parsed = statsConfigStr;
|
||||
} else {
|
||||
try {
|
||||
let temp: any = JSON.parse(statsConfigStr);
|
||||
|
||||
if (typeof temp === "string") {
|
||||
temp = JSON.parse(temp);
|
||||
}
|
||||
|
||||
parsed = temp;
|
||||
} catch (error) {
|
||||
statsLogger.warn(
|
||||
`Failed to parse statsConfig: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
{
|
||||
operation: "parse_stats_config_error",
|
||||
statsConfigStr,
|
||||
},
|
||||
);
|
||||
return DEFAULT_STATS_CONFIG;
|
||||
}
|
||||
}
|
||||
|
||||
const result = { ...DEFAULT_STATS_CONFIG, ...parsed };
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async startPollingForHost(host: SSHHostWithCredentials): Promise<void> {
|
||||
const statsConfig = this.parseStatsConfig(host.statsConfig);
|
||||
|
||||
const existingConfig = this.pollingConfigs.get(host.id);
|
||||
|
||||
if (existingConfig) {
|
||||
if (existingConfig.statusTimer) {
|
||||
clearInterval(existingConfig.statusTimer);
|
||||
existingConfig.statusTimer = undefined;
|
||||
}
|
||||
if (existingConfig.metricsTimer) {
|
||||
clearInterval(existingConfig.metricsTimer);
|
||||
existingConfig.metricsTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (!statsConfig.statusCheckEnabled && !statsConfig.metricsEnabled) {
|
||||
this.pollingConfigs.delete(host.id);
|
||||
this.statusStore.delete(host.id);
|
||||
this.metricsStore.delete(host.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const config: HostPollingConfig = {
|
||||
host,
|
||||
statsConfig,
|
||||
@@ -466,7 +714,10 @@ class PollingManager {
|
||||
this.pollHostStatus(host);
|
||||
|
||||
config.statusTimer = setInterval(() => {
|
||||
this.pollHostStatus(host);
|
||||
const latestConfig = this.pollingConfigs.get(host.id);
|
||||
if (latestConfig && latestConfig.statsConfig.statusCheckEnabled) {
|
||||
this.pollHostStatus(latestConfig.host);
|
||||
}
|
||||
}, intervalMs);
|
||||
} else {
|
||||
this.statusStore.delete(host.id);
|
||||
@@ -478,7 +729,10 @@ class PollingManager {
|
||||
this.pollHostMetrics(host);
|
||||
|
||||
config.metricsTimer = setInterval(() => {
|
||||
this.pollHostMetrics(host);
|
||||
const latestConfig = this.pollingConfigs.get(host.id);
|
||||
if (latestConfig && latestConfig.statsConfig.metricsEnabled) {
|
||||
this.pollHostMetrics(latestConfig.host);
|
||||
}
|
||||
}, intervalMs);
|
||||
} else {
|
||||
this.metricsStore.delete(host.id);
|
||||
@@ -505,27 +759,51 @@ class PollingManager {
|
||||
}
|
||||
|
||||
private async pollHostMetrics(host: SSHHostWithCredentials): Promise<void> {
|
||||
const config = this.pollingConfigs.get(host.id);
|
||||
if (!config || !config.statsConfig.metricsEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentHost = config.host;
|
||||
|
||||
try {
|
||||
const metrics = await collectMetrics(host);
|
||||
this.metricsStore.set(host.id, {
|
||||
const metrics = await collectMetrics(currentHost);
|
||||
this.metricsStore.set(currentHost.id, {
|
||||
data: metrics,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} catch (error) {}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
const latestConfig = this.pollingConfigs.get(currentHost.id);
|
||||
if (latestConfig && latestConfig.statsConfig.metricsEnabled) {
|
||||
statsLogger.warn("Failed to collect metrics for host", {
|
||||
operation: "metrics_poll_failed",
|
||||
hostId: currentHost.id,
|
||||
hostName: currentHost.name,
|
||||
error: errorMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stopPollingForHost(hostId: number): void {
|
||||
stopPollingForHost(hostId: number, clearData = true): void {
|
||||
const config = this.pollingConfigs.get(hostId);
|
||||
if (config) {
|
||||
if (config.statusTimer) {
|
||||
clearInterval(config.statusTimer);
|
||||
config.statusTimer = undefined;
|
||||
}
|
||||
if (config.metricsTimer) {
|
||||
clearInterval(config.metricsTimer);
|
||||
config.metricsTimer = undefined;
|
||||
}
|
||||
this.pollingConfigs.delete(hostId);
|
||||
this.statusStore.delete(hostId);
|
||||
this.metricsStore.delete(hostId);
|
||||
if (clearData) {
|
||||
this.statusStore.delete(hostId);
|
||||
this.metricsStore.delete(hostId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -554,11 +832,23 @@ class PollingManager {
|
||||
}
|
||||
|
||||
async refreshHostPolling(userId: string): Promise<void> {
|
||||
const hosts = await fetchAllHosts(userId);
|
||||
const currentHostIds = new Set(hosts.map((h) => h.id));
|
||||
|
||||
for (const hostId of this.pollingConfigs.keys()) {
|
||||
this.stopPollingForHost(hostId);
|
||||
this.stopPollingForHost(hostId, false);
|
||||
}
|
||||
|
||||
await this.initializePolling(userId);
|
||||
for (const hostId of this.statusStore.keys()) {
|
||||
if (!currentHostIds.has(hostId)) {
|
||||
this.statusStore.delete(hostId);
|
||||
this.metricsStore.delete(hostId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const host of hosts) {
|
||||
await this.startPollingForHost(host);
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
@@ -712,6 +1002,7 @@ async function resolveHostCredentials(
|
||||
tunnelConnections: host.tunnelConnections
|
||||
? JSON.parse(host.tunnelConnections as string)
|
||||
: [],
|
||||
jumpHosts: host.jumpHosts ? JSON.parse(host.jumpHosts as string) : [],
|
||||
statsConfig: host.statsConfig || undefined,
|
||||
createdAt: host.createdAt,
|
||||
updatedAt: host.updatedAt,
|
||||
@@ -911,59 +1202,6 @@ async function withSshConnection<T>(
|
||||
}
|
||||
}
|
||||
|
||||
function execCommand(
|
||||
client: Client,
|
||||
command: string,
|
||||
): Promise<{
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number | null;
|
||||
}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
client.exec(command, { pty: false }, (err, stream) => {
|
||||
if (err) return reject(err);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let exitCode: number | null = null;
|
||||
stream
|
||||
.on("close", (code: number | undefined) => {
|
||||
exitCode = typeof code === "number" ? code : null;
|
||||
resolve({ stdout, stderr, code: exitCode });
|
||||
})
|
||||
.on("data", (data: Buffer) => {
|
||||
stdout += data.toString("utf8");
|
||||
})
|
||||
.stderr.on("data", (data: Buffer) => {
|
||||
stderr += data.toString("utf8");
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function parseCpuLine(
|
||||
cpuLine: string,
|
||||
): { total: number; idle: number } | undefined {
|
||||
const parts = cpuLine.trim().split(/\s+/);
|
||||
if (parts[0] !== "cpu") return undefined;
|
||||
const nums = parts
|
||||
.slice(1)
|
||||
.map((n) => Number(n))
|
||||
.filter((n) => Number.isFinite(n));
|
||||
if (nums.length < 4) return undefined;
|
||||
const idle = (nums[3] ?? 0) + (nums[4] ?? 0);
|
||||
const total = nums.reduce((a, b) => a + b, 0);
|
||||
return { total, idle };
|
||||
}
|
||||
|
||||
function toFixedNum(n: number | null | undefined, digits = 2): number | null {
|
||||
if (typeof n !== "number" || !Number.isFinite(n)) return null;
|
||||
return Number(n.toFixed(digits));
|
||||
}
|
||||
|
||||
function kibToGiB(kib: number): number {
|
||||
return kib / (1024 * 1024);
|
||||
}
|
||||
|
||||
async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
||||
cpu: {
|
||||
percent: number | null;
|
||||
@@ -1026,298 +1264,38 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
||||
return requestQueue.queueRequest(host.id, async () => {
|
||||
try {
|
||||
return await withSshConnection(host, async (client) => {
|
||||
let cpuPercent: number | null = null;
|
||||
let cores: number | null = null;
|
||||
let loadTriplet: [number, number, number] | null = null;
|
||||
const cpu = await collectCpuMetrics(client);
|
||||
const memory = await collectMemoryMetrics(client);
|
||||
const disk = await collectDiskMetrics(client);
|
||||
const network = await collectNetworkMetrics(client);
|
||||
const uptime = await collectUptimeMetrics(client);
|
||||
const processes = await collectProcessesMetrics(client);
|
||||
const system = await collectSystemMetrics(client);
|
||||
|
||||
let login_stats = {
|
||||
recentLogins: [],
|
||||
failedLogins: [],
|
||||
totalLogins: 0,
|
||||
uniqueIPs: 0,
|
||||
};
|
||||
try {
|
||||
const [stat1, loadAvgOut, coresOut] = await Promise.all([
|
||||
execCommand(client, "cat /proc/stat"),
|
||||
execCommand(client, "cat /proc/loadavg"),
|
||||
execCommand(
|
||||
client,
|
||||
"nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo",
|
||||
),
|
||||
]);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
const stat2 = await execCommand(client, "cat /proc/stat");
|
||||
|
||||
const cpuLine1 = (
|
||||
stat1.stdout.split("\n").find((l) => l.startsWith("cpu ")) || ""
|
||||
).trim();
|
||||
const cpuLine2 = (
|
||||
stat2.stdout.split("\n").find((l) => l.startsWith("cpu ")) || ""
|
||||
).trim();
|
||||
const a = parseCpuLine(cpuLine1);
|
||||
const b = parseCpuLine(cpuLine2);
|
||||
if (a && b) {
|
||||
const totalDiff = b.total - a.total;
|
||||
const idleDiff = b.idle - a.idle;
|
||||
const used = totalDiff - idleDiff;
|
||||
if (totalDiff > 0)
|
||||
cpuPercent = Math.max(0, Math.min(100, (used / totalDiff) * 100));
|
||||
}
|
||||
|
||||
const laParts = loadAvgOut.stdout.trim().split(/\s+/);
|
||||
if (laParts.length >= 3) {
|
||||
loadTriplet = [
|
||||
Number(laParts[0]),
|
||||
Number(laParts[1]),
|
||||
Number(laParts[2]),
|
||||
].map((v) => (Number.isFinite(v) ? Number(v) : 0)) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
}
|
||||
|
||||
const coresNum = Number((coresOut.stdout || "").trim());
|
||||
cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null;
|
||||
login_stats = await collectLoginStats(client);
|
||||
} catch (e) {
|
||||
cpuPercent = null;
|
||||
cores = null;
|
||||
loadTriplet = null;
|
||||
statsLogger.debug("Failed to collect login stats", {
|
||||
operation: "login_stats_failed",
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
|
||||
let memPercent: number | null = null;
|
||||
let usedGiB: number | null = null;
|
||||
let totalGiB: number | null = null;
|
||||
try {
|
||||
const memInfo = await execCommand(client, "cat /proc/meminfo");
|
||||
const lines = memInfo.stdout.split("\n");
|
||||
const getVal = (key: string) => {
|
||||
const line = lines.find((l) => l.startsWith(key));
|
||||
if (!line) return null;
|
||||
const m = line.match(/\d+/);
|
||||
return m ? Number(m[0]) : null;
|
||||
};
|
||||
const totalKb = getVal("MemTotal:");
|
||||
const availKb = getVal("MemAvailable:");
|
||||
if (totalKb && availKb && totalKb > 0) {
|
||||
const usedKb = totalKb - availKb;
|
||||
memPercent = Math.max(0, Math.min(100, (usedKb / totalKb) * 100));
|
||||
usedGiB = kibToGiB(usedKb);
|
||||
totalGiB = kibToGiB(totalKb);
|
||||
}
|
||||
} catch (e) {
|
||||
memPercent = null;
|
||||
usedGiB = null;
|
||||
totalGiB = null;
|
||||
}
|
||||
|
||||
let diskPercent: number | null = null;
|
||||
let usedHuman: string | null = null;
|
||||
let totalHuman: string | null = null;
|
||||
let availableHuman: string | null = null;
|
||||
try {
|
||||
const [diskOutHuman, diskOutBytes] = await Promise.all([
|
||||
execCommand(client, "df -h -P / | tail -n +2"),
|
||||
execCommand(client, "df -B1 -P / | tail -n +2"),
|
||||
]);
|
||||
|
||||
const humanLine =
|
||||
diskOutHuman.stdout
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)[0] || "";
|
||||
const bytesLine =
|
||||
diskOutBytes.stdout
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)[0] || "";
|
||||
|
||||
const humanParts = humanLine.split(/\s+/);
|
||||
const bytesParts = bytesLine.split(/\s+/);
|
||||
|
||||
if (humanParts.length >= 6 && bytesParts.length >= 6) {
|
||||
totalHuman = humanParts[1] || null;
|
||||
usedHuman = humanParts[2] || null;
|
||||
availableHuman = humanParts[3] || null;
|
||||
|
||||
const totalBytes = Number(bytesParts[1]);
|
||||
const usedBytes = Number(bytesParts[2]);
|
||||
|
||||
if (
|
||||
Number.isFinite(totalBytes) &&
|
||||
Number.isFinite(usedBytes) &&
|
||||
totalBytes > 0
|
||||
) {
|
||||
diskPercent = Math.max(
|
||||
0,
|
||||
Math.min(100, (usedBytes / totalBytes) * 100),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
diskPercent = null;
|
||||
usedHuman = null;
|
||||
totalHuman = null;
|
||||
availableHuman = null;
|
||||
}
|
||||
|
||||
const interfaces: Array<{
|
||||
name: string;
|
||||
ip: string;
|
||||
state: string;
|
||||
rxBytes: string | null;
|
||||
txBytes: string | null;
|
||||
}> = [];
|
||||
try {
|
||||
const ifconfigOut = await execCommand(
|
||||
client,
|
||||
"ip -o addr show | awk '{print $2,$4}' | grep -v '^lo'",
|
||||
);
|
||||
const netStatOut = await execCommand(
|
||||
client,
|
||||
"ip -o link show | awk '{gsub(/:/, \"\", $2); print $2,$9}'",
|
||||
);
|
||||
|
||||
const addrs = ifconfigOut.stdout
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
const states = netStatOut.stdout
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const ifMap = new Map<string, { ip: string; state: string }>();
|
||||
for (const line of addrs) {
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
const name = parts[0];
|
||||
const ip = parts[1].split("/")[0];
|
||||
if (!ifMap.has(name)) ifMap.set(name, { ip, state: "UNKNOWN" });
|
||||
}
|
||||
}
|
||||
for (const line of states) {
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
const name = parts[0];
|
||||
const state = parts[1];
|
||||
const existing = ifMap.get(name);
|
||||
if (existing) {
|
||||
existing.state = state;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, data] of ifMap.entries()) {
|
||||
interfaces.push({
|
||||
name,
|
||||
ip: data.ip,
|
||||
state: data.state,
|
||||
rxBytes: null,
|
||||
txBytes: null,
|
||||
});
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
let uptimeSeconds: number | null = null;
|
||||
let uptimeFormatted: string | null = null;
|
||||
try {
|
||||
const uptimeOut = await execCommand(client, "cat /proc/uptime");
|
||||
const uptimeParts = uptimeOut.stdout.trim().split(/\s+/);
|
||||
if (uptimeParts.length >= 1) {
|
||||
uptimeSeconds = Number(uptimeParts[0]);
|
||||
if (Number.isFinite(uptimeSeconds)) {
|
||||
const days = Math.floor(uptimeSeconds / 86400);
|
||||
const hours = Math.floor((uptimeSeconds % 86400) / 3600);
|
||||
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
|
||||
uptimeFormatted = `${days}d ${hours}h ${minutes}m`;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
let totalProcesses: number | null = null;
|
||||
let runningProcesses: number | null = null;
|
||||
const topProcesses: Array<{
|
||||
pid: string;
|
||||
user: string;
|
||||
cpu: string;
|
||||
mem: string;
|
||||
command: string;
|
||||
}> = [];
|
||||
try {
|
||||
const psOut = await execCommand(
|
||||
client,
|
||||
"ps aux --sort=-%cpu | head -n 11",
|
||||
);
|
||||
const psLines = psOut.stdout
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
if (psLines.length > 1) {
|
||||
for (let i = 1; i < Math.min(psLines.length, 11); i++) {
|
||||
const parts = psLines[i].split(/\s+/);
|
||||
if (parts.length >= 11) {
|
||||
topProcesses.push({
|
||||
pid: parts[1],
|
||||
user: parts[0],
|
||||
cpu: parts[2],
|
||||
mem: parts[3],
|
||||
command: parts.slice(10).join(" ").substring(0, 50),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const procCount = await execCommand(client, "ps aux | wc -l");
|
||||
const runningCount = await execCommand(
|
||||
client,
|
||||
"ps aux | grep -c ' R '",
|
||||
);
|
||||
totalProcesses = Number(procCount.stdout.trim()) - 1;
|
||||
runningProcesses = Number(runningCount.stdout.trim());
|
||||
} catch (e) {}
|
||||
|
||||
let hostname: string | null = null;
|
||||
let kernel: string | null = null;
|
||||
let os: string | null = null;
|
||||
try {
|
||||
const hostnameOut = await execCommand(client, "hostname");
|
||||
const kernelOut = await execCommand(client, "uname -r");
|
||||
const osOut = await execCommand(
|
||||
client,
|
||||
"cat /etc/os-release | grep '^PRETTY_NAME=' | cut -d'\"' -f2",
|
||||
);
|
||||
|
||||
hostname = hostnameOut.stdout.trim() || null;
|
||||
kernel = kernelOut.stdout.trim() || null;
|
||||
os = osOut.stdout.trim() || null;
|
||||
} catch (e) {}
|
||||
|
||||
const result = {
|
||||
cpu: { percent: toFixedNum(cpuPercent, 0), cores, load: loadTriplet },
|
||||
memory: {
|
||||
percent: toFixedNum(memPercent, 0),
|
||||
usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null,
|
||||
totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null,
|
||||
},
|
||||
disk: {
|
||||
percent: toFixedNum(diskPercent, 0),
|
||||
usedHuman,
|
||||
totalHuman,
|
||||
availableHuman,
|
||||
},
|
||||
network: {
|
||||
interfaces,
|
||||
},
|
||||
uptime: {
|
||||
seconds: uptimeSeconds,
|
||||
formatted: uptimeFormatted,
|
||||
},
|
||||
processes: {
|
||||
total: totalProcesses,
|
||||
running: runningProcesses,
|
||||
top: topProcesses,
|
||||
},
|
||||
system: {
|
||||
hostname,
|
||||
kernel,
|
||||
os,
|
||||
},
|
||||
cpu,
|
||||
memory,
|
||||
disk,
|
||||
network,
|
||||
uptime,
|
||||
processes,
|
||||
system,
|
||||
login_stats,
|
||||
};
|
||||
|
||||
metricsCache.set(host.id, result);
|
||||
@@ -1365,7 +1343,7 @@ function tcpPing(
|
||||
settled = true;
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch {}
|
||||
} catch (error) {}
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
@@ -1438,6 +1416,67 @@ app.post("/refresh", async (req, res) => {
|
||||
res.json({ message: "Polling refreshed" });
|
||||
});
|
||||
|
||||
app.post("/host-updated", async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hostId } = req.body;
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
if (!hostId || typeof hostId !== "number") {
|
||||
return res.status(400).json({ error: "Invalid hostId" });
|
||||
}
|
||||
|
||||
try {
|
||||
const host = await fetchHostById(hostId, userId);
|
||||
if (host) {
|
||||
await pollingManager.startPollingForHost(host);
|
||||
res.json({ message: "Host polling started" });
|
||||
} else {
|
||||
res.status(404).json({ error: "Host not found" });
|
||||
}
|
||||
} catch (error) {
|
||||
statsLogger.error("Failed to start polling for host", error, {
|
||||
operation: "host_updated",
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to start polling" });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/host-deleted", async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hostId } = req.body;
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
if (!hostId || typeof hostId !== "number") {
|
||||
return res.status(400).json({ error: "Invalid hostId" });
|
||||
}
|
||||
|
||||
try {
|
||||
pollingManager.stopPollingForHost(hostId, true);
|
||||
res.json({ message: "Host polling stopped" });
|
||||
} catch (error) {
|
||||
statsLogger.error("Failed to stop polling for host", error, {
|
||||
operation: "host_deleted",
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to stop polling" });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/metrics/:id", validateHostId, async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
@@ -31,6 +31,7 @@ interface ConnectToHostData {
|
||||
credentialId?: number;
|
||||
userId?: string;
|
||||
forceKeyboardInteractive?: boolean;
|
||||
jumpHosts?: Array<{ hostId: number }>;
|
||||
};
|
||||
initialPath?: string;
|
||||
executeCommand?: string;
|
||||
@@ -57,6 +58,173 @@ const userCrypto = UserCrypto.getInstance();
|
||||
|
||||
const userConnections = new Map<string, Set<WebSocket>>();
|
||||
|
||||
async function resolveJumpHost(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
): Promise<any | null> {
|
||||
try {
|
||||
const hosts = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (hosts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const host = hosts[0];
|
||||
|
||||
if (host.credentialId) {
|
||||
const credentials = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, host.credentialId as number),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
return {
|
||||
...host,
|
||||
password: credential.password,
|
||||
key:
|
||||
credential.private_key || credential.privateKey || credential.key,
|
||||
keyPassword: credential.key_password || credential.keyPassword,
|
||||
keyType: credential.key_type || credential.keyType,
|
||||
authType: credential.auth_type || credential.authType,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return host;
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to resolve jump host", error, {
|
||||
operation: "resolve_jump_host",
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function createJumpHostChain(
|
||||
jumpHosts: Array<{ hostId: number }>,
|
||||
userId: string,
|
||||
): Promise<Client | null> {
|
||||
if (!jumpHosts || jumpHosts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let currentClient: Client | null = null;
|
||||
const clients: Client[] = [];
|
||||
|
||||
try {
|
||||
for (let i = 0; i < jumpHosts.length; i++) {
|
||||
const jumpHostConfig = await resolveJumpHost(jumpHosts[i].hostId, userId);
|
||||
|
||||
if (!jumpHostConfig) {
|
||||
sshLogger.error(`Jump host ${i + 1} not found`, undefined, {
|
||||
operation: "jump_host_chain",
|
||||
hostId: jumpHosts[i].hostId,
|
||||
});
|
||||
clients.forEach((c) => c.end());
|
||||
return null;
|
||||
}
|
||||
|
||||
const jumpClient = new Client();
|
||||
clients.push(jumpClient);
|
||||
|
||||
const connected = await new Promise<boolean>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
resolve(false);
|
||||
}, 30000);
|
||||
|
||||
jumpClient.on("ready", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
jumpClient.on("error", (err) => {
|
||||
clearTimeout(timeout);
|
||||
sshLogger.error(`Jump host ${i + 1} connection failed`, err, {
|
||||
operation: "jump_host_connect",
|
||||
hostId: jumpHostConfig.id,
|
||||
ip: jumpHostConfig.ip,
|
||||
});
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
const connectConfig: any = {
|
||||
host: jumpHostConfig.ip,
|
||||
port: jumpHostConfig.port || 22,
|
||||
username: jumpHostConfig.username,
|
||||
tryKeyboard: true,
|
||||
readyTimeout: 30000,
|
||||
};
|
||||
|
||||
if (jumpHostConfig.authType === "password" && jumpHostConfig.password) {
|
||||
connectConfig.password = jumpHostConfig.password;
|
||||
} else if (jumpHostConfig.authType === "key" && jumpHostConfig.key) {
|
||||
const cleanKey = jumpHostConfig.key
|
||||
.trim()
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n");
|
||||
connectConfig.privateKey = Buffer.from(cleanKey, "utf8");
|
||||
if (jumpHostConfig.keyPassword) {
|
||||
connectConfig.passphrase = jumpHostConfig.keyPassword;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentClient) {
|
||||
currentClient.forwardOut(
|
||||
"127.0.0.1",
|
||||
0,
|
||||
jumpHostConfig.ip,
|
||||
jumpHostConfig.port || 22,
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(timeout);
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
connectConfig.sock = stream;
|
||||
jumpClient.connect(connectConfig);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
jumpClient.connect(connectConfig);
|
||||
}
|
||||
});
|
||||
|
||||
if (!connected) {
|
||||
clients.forEach((c) => c.end());
|
||||
return null;
|
||||
}
|
||||
|
||||
currentClient = jumpClient;
|
||||
}
|
||||
|
||||
return currentClient;
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to create jump host chain", error, {
|
||||
operation: "jump_host_chain",
|
||||
});
|
||||
clients.forEach((c) => c.end());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const wss = new WebSocketServer({
|
||||
port: 30002,
|
||||
verifyClient: async (info) => {
|
||||
@@ -79,6 +247,7 @@ const wss = new WebSocketServer({
|
||||
}
|
||||
|
||||
const existingConnections = userConnections.get(payload.userId);
|
||||
|
||||
if (existingConnections && existingConnections.size >= 3) {
|
||||
return false;
|
||||
}
|
||||
@@ -147,11 +316,14 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
|
||||
let sshConn: Client | null = null;
|
||||
let sshStream: ClientChannel | null = null;
|
||||
let pingInterval: NodeJS.Timeout | null = null;
|
||||
let keyboardInteractiveFinish: ((responses: string[]) => void) | null = null;
|
||||
let totpPromptSent = false;
|
||||
let isKeyboardInteractive = false;
|
||||
let keyboardInteractiveResponded = false;
|
||||
let isConnecting = false;
|
||||
let isConnected = false;
|
||||
let isCleaningUp = false;
|
||||
let isShellInitializing = false;
|
||||
|
||||
ws.on("close", () => {
|
||||
const userWs = userConnections.get(userId);
|
||||
@@ -417,10 +589,21 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isConnecting || isConnected) {
|
||||
sshLogger.warn("Connection already in progress or established", {
|
||||
operation: "ssh_connect",
|
||||
hostId: id,
|
||||
isConnecting,
|
||||
isConnected,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
isConnecting = true;
|
||||
sshConn = new Client();
|
||||
|
||||
const connectionTimeout = setTimeout(() => {
|
||||
if (sshConn) {
|
||||
if (sshConn && isConnecting && !isConnected) {
|
||||
sshLogger.error("SSH connection timeout", undefined, {
|
||||
operation: "ssh_connect",
|
||||
hostId: id,
|
||||
@@ -433,7 +616,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
);
|
||||
cleanupSSH(connectionTimeout);
|
||||
}
|
||||
}, 60000);
|
||||
}, 120000);
|
||||
|
||||
let resolvedCredentials = { password, key, keyPassword, keyType, authType };
|
||||
let authMethodNotAvailable = false;
|
||||
@@ -498,7 +681,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
sshConn.on("ready", () => {
|
||||
clearTimeout(connectionTimeout);
|
||||
|
||||
if (!sshConn) {
|
||||
const conn = sshConn;
|
||||
|
||||
if (!conn || isCleaningUp || !sshConn) {
|
||||
sshLogger.warn(
|
||||
"SSH connection was cleaned up before shell could be created",
|
||||
{
|
||||
@@ -507,6 +692,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
ip,
|
||||
port,
|
||||
username,
|
||||
isCleaningUp,
|
||||
connNull: !conn,
|
||||
sshConnNull: !sshConn,
|
||||
},
|
||||
);
|
||||
ws.send(
|
||||
@@ -519,13 +707,37 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
return;
|
||||
}
|
||||
|
||||
sshConn.shell(
|
||||
isShellInitializing = true;
|
||||
isConnecting = false;
|
||||
isConnected = true;
|
||||
|
||||
if (!sshConn) {
|
||||
sshLogger.error(
|
||||
"SSH connection became null right before shell creation",
|
||||
{
|
||||
operation: "ssh_shell",
|
||||
hostId: id,
|
||||
},
|
||||
);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "SSH connection lost during setup",
|
||||
}),
|
||||
);
|
||||
isShellInitializing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
conn.shell(
|
||||
{
|
||||
rows: data.rows,
|
||||
cols: data.cols,
|
||||
term: "xterm-256color",
|
||||
} as PseudoTtyOptions,
|
||||
(err, stream) => {
|
||||
isShellInitializing = false;
|
||||
|
||||
if (err) {
|
||||
sshLogger.error("Shell error", err, {
|
||||
operation: "ssh_shell",
|
||||
@@ -589,8 +801,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
);
|
||||
});
|
||||
|
||||
setupPingInterval();
|
||||
|
||||
if (initialPath && initialPath.trim() !== "") {
|
||||
const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}" && pwd\n`;
|
||||
stream.write(cdCommand);
|
||||
@@ -836,9 +1046,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
tryKeyboard: true,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
readyTimeout: 60000,
|
||||
readyTimeout: 120000,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
timeout: 120000,
|
||||
env: {
|
||||
TERM: "xterm-256color",
|
||||
LANG: "en_US.UTF-8",
|
||||
@@ -969,7 +1180,68 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
return;
|
||||
}
|
||||
|
||||
sshConn.connect(connectConfig);
|
||||
if (
|
||||
hostConfig.jumpHosts &&
|
||||
hostConfig.jumpHosts.length > 0 &&
|
||||
hostConfig.userId
|
||||
) {
|
||||
try {
|
||||
const jumpClient = await createJumpHostChain(
|
||||
hostConfig.jumpHosts,
|
||||
hostConfig.userId,
|
||||
);
|
||||
|
||||
if (!jumpClient) {
|
||||
sshLogger.error("Failed to establish jump host chain");
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Failed to connect through jump hosts",
|
||||
}),
|
||||
);
|
||||
cleanupSSH(connectionTimeout);
|
||||
return;
|
||||
}
|
||||
|
||||
jumpClient.forwardOut("127.0.0.1", 0, ip, port, (err, stream) => {
|
||||
if (err) {
|
||||
sshLogger.error("Failed to forward through jump host", err, {
|
||||
operation: "ssh_jump_forward",
|
||||
hostId: id,
|
||||
ip,
|
||||
port,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Failed to forward through jump host: " + err.message,
|
||||
}),
|
||||
);
|
||||
jumpClient.end();
|
||||
cleanupSSH(connectionTimeout);
|
||||
return;
|
||||
}
|
||||
|
||||
connectConfig.sock = stream;
|
||||
sshConn.connect(connectConfig);
|
||||
});
|
||||
} catch (error) {
|
||||
sshLogger.error("Jump host error", error, {
|
||||
operation: "ssh_jump_host",
|
||||
hostId: id,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Failed to connect through jump hosts",
|
||||
}),
|
||||
);
|
||||
cleanupSSH(connectionTimeout);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
sshConn.connect(connectConfig);
|
||||
}
|
||||
}
|
||||
|
||||
function handleResize(data: ResizeData) {
|
||||
@@ -982,13 +1254,26 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
}
|
||||
|
||||
function cleanupSSH(timeoutId?: NodeJS.Timeout) {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
if (isCleaningUp) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pingInterval) {
|
||||
clearInterval(pingInterval);
|
||||
pingInterval = null;
|
||||
if (isShellInitializing) {
|
||||
sshLogger.warn(
|
||||
"Cleanup attempted during shell initialization, deferring",
|
||||
{
|
||||
operation: "cleanup_deferred",
|
||||
userId,
|
||||
},
|
||||
);
|
||||
setTimeout(() => cleanupSSH(timeoutId), 100);
|
||||
return;
|
||||
}
|
||||
|
||||
isCleaningUp = true;
|
||||
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
if (sshStream) {
|
||||
@@ -1019,21 +1304,20 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
isKeyboardInteractive = false;
|
||||
keyboardInteractiveResponded = false;
|
||||
keyboardInteractiveFinish = null;
|
||||
isConnecting = false;
|
||||
isConnected = false;
|
||||
|
||||
setTimeout(() => {
|
||||
isCleaningUp = false;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function setupPingInterval() {
|
||||
pingInterval = setInterval(() => {
|
||||
if (sshConn && sshStream) {
|
||||
try {
|
||||
sshStream.write("\x00");
|
||||
} catch (e: unknown) {
|
||||
sshLogger.error(
|
||||
"SSH keepalive failed: " +
|
||||
(e instanceof Error ? e.message : "Unknown error"),
|
||||
);
|
||||
cleanupSSH();
|
||||
}
|
||||
}
|
||||
}, 60000);
|
||||
}
|
||||
// Note: PTY-level keepalive (writing \x00 to the stream) was removed.
|
||||
// It was causing ^@ characters to appear in terminals with echoctl enabled.
|
||||
// SSH-level keepalive is configured via connectConfig (keepaliveInterval,
|
||||
// keepaliveCountMax, tcpKeepAlive), which handles connection health monitoring
|
||||
// without producing visible output on the terminal.
|
||||
//
|
||||
// See: https://github.com/Termix-SSH/Support/issues/232
|
||||
// See: https://github.com/Termix-SSH/Support/issues/309
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
ErrorType,
|
||||
} from "../../types/index.js";
|
||||
import { CONNECTION_STATES } from "../../types/index.js";
|
||||
import { tunnelLogger } from "../utils/logger.js";
|
||||
import { tunnelLogger, sshLogger } from "../utils/logger.js";
|
||||
import { SystemCrypto } from "../utils/system-crypto.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { DataCrypto } from "../utils/data-crypto.js";
|
||||
@@ -217,7 +217,7 @@ function cleanupTunnelResources(
|
||||
if (verification?.timeout) clearTimeout(verification.timeout);
|
||||
try {
|
||||
verification?.conn.end();
|
||||
} catch {}
|
||||
} catch (error) {}
|
||||
tunnelVerifications.delete(tunnelName);
|
||||
}
|
||||
|
||||
@@ -282,7 +282,7 @@ function handleDisconnect(
|
||||
const verification = tunnelVerifications.get(tunnelName);
|
||||
if (verification?.timeout) clearTimeout(verification.timeout);
|
||||
verification?.conn.end();
|
||||
} catch {}
|
||||
} catch (error) {}
|
||||
tunnelVerifications.delete(tunnelName);
|
||||
}
|
||||
|
||||
@@ -638,7 +638,7 @@ async function connectSSHTunnel(
|
||||
|
||||
try {
|
||||
conn.end();
|
||||
} catch {}
|
||||
} catch (error) {}
|
||||
|
||||
activeTunnels.delete(tunnelName);
|
||||
|
||||
@@ -778,7 +778,7 @@ async function connectSSHTunnel(
|
||||
const verification = tunnelVerifications.get(tunnelName);
|
||||
if (verification?.timeout) clearTimeout(verification.timeout);
|
||||
verification?.conn.end();
|
||||
} catch {}
|
||||
} catch (error) {}
|
||||
tunnelVerifications.delete(tunnelName);
|
||||
}
|
||||
|
||||
|
||||
42
src/backend/ssh/widgets/common-utils.ts
Normal file
42
src/backend/ssh/widgets/common-utils.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
export function execCommand(
|
||||
client: Client,
|
||||
command: string,
|
||||
): Promise<{
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number | null;
|
||||
}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
client.exec(command, { pty: false }, (err, stream) => {
|
||||
if (err) return reject(err);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let exitCode: number | null = null;
|
||||
stream
|
||||
.on("close", (code: number | undefined) => {
|
||||
exitCode = typeof code === "number" ? code : null;
|
||||
resolve({ stdout, stderr, code: exitCode });
|
||||
})
|
||||
.on("data", (data: Buffer) => {
|
||||
stdout += data.toString("utf8");
|
||||
})
|
||||
.stderr.on("data", (data: Buffer) => {
|
||||
stderr += data.toString("utf8");
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function toFixedNum(
|
||||
n: number | null | undefined,
|
||||
digits = 2,
|
||||
): number | null {
|
||||
if (typeof n !== "number" || !Number.isFinite(n)) return null;
|
||||
return Number(n.toFixed(digits));
|
||||
}
|
||||
|
||||
export function kibToGiB(kib: number): number {
|
||||
return kib / (1024 * 1024);
|
||||
}
|
||||
83
src/backend/ssh/widgets/cpu-collector.ts
Normal file
83
src/backend/ssh/widgets/cpu-collector.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { Client } from "ssh2";
|
||||
import { execCommand, toFixedNum } from "./common-utils.js";
|
||||
|
||||
function parseCpuLine(
|
||||
cpuLine: string,
|
||||
): { total: number; idle: number } | undefined {
|
||||
const parts = cpuLine.trim().split(/\s+/);
|
||||
if (parts[0] !== "cpu") return undefined;
|
||||
const nums = parts
|
||||
.slice(1)
|
||||
.map((n) => Number(n))
|
||||
.filter((n) => Number.isFinite(n));
|
||||
if (nums.length < 4) return undefined;
|
||||
const idle = (nums[3] ?? 0) + (nums[4] ?? 0);
|
||||
const total = nums.reduce((a, b) => a + b, 0);
|
||||
return { total, idle };
|
||||
}
|
||||
|
||||
export async function collectCpuMetrics(client: Client): Promise<{
|
||||
percent: number | null;
|
||||
cores: number | null;
|
||||
load: [number, number, number] | null;
|
||||
}> {
|
||||
let cpuPercent: number | null = null;
|
||||
let cores: number | null = null;
|
||||
let loadTriplet: [number, number, number] | null = null;
|
||||
|
||||
try {
|
||||
const [stat1, loadAvgOut, coresOut] = await Promise.all([
|
||||
execCommand(client, "cat /proc/stat"),
|
||||
execCommand(client, "cat /proc/loadavg"),
|
||||
execCommand(
|
||||
client,
|
||||
"nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo",
|
||||
),
|
||||
]);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
const stat2 = await execCommand(client, "cat /proc/stat");
|
||||
|
||||
const cpuLine1 = (
|
||||
stat1.stdout.split("\n").find((l) => l.startsWith("cpu ")) || ""
|
||||
).trim();
|
||||
const cpuLine2 = (
|
||||
stat2.stdout.split("\n").find((l) => l.startsWith("cpu ")) || ""
|
||||
).trim();
|
||||
const a = parseCpuLine(cpuLine1);
|
||||
const b = parseCpuLine(cpuLine2);
|
||||
if (a && b) {
|
||||
const totalDiff = b.total - a.total;
|
||||
const idleDiff = b.idle - a.idle;
|
||||
const used = totalDiff - idleDiff;
|
||||
if (totalDiff > 0)
|
||||
cpuPercent = Math.max(0, Math.min(100, (used / totalDiff) * 100));
|
||||
}
|
||||
|
||||
const laParts = loadAvgOut.stdout.trim().split(/\s+/);
|
||||
if (laParts.length >= 3) {
|
||||
loadTriplet = [
|
||||
Number(laParts[0]),
|
||||
Number(laParts[1]),
|
||||
Number(laParts[2]),
|
||||
].map((v) => (Number.isFinite(v) ? Number(v) : 0)) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
}
|
||||
|
||||
const coresNum = Number((coresOut.stdout || "").trim());
|
||||
cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null;
|
||||
} catch (e) {
|
||||
cpuPercent = null;
|
||||
cores = null;
|
||||
loadTriplet = null;
|
||||
}
|
||||
|
||||
return {
|
||||
percent: toFixedNum(cpuPercent, 0),
|
||||
cores,
|
||||
load: loadTriplet,
|
||||
};
|
||||
}
|
||||
67
src/backend/ssh/widgets/disk-collector.ts
Normal file
67
src/backend/ssh/widgets/disk-collector.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { Client } from "ssh2";
|
||||
import { execCommand, toFixedNum } from "./common-utils.js";
|
||||
|
||||
export async function collectDiskMetrics(client: Client): Promise<{
|
||||
percent: number | null;
|
||||
usedHuman: string | null;
|
||||
totalHuman: string | null;
|
||||
availableHuman: string | null;
|
||||
}> {
|
||||
let diskPercent: number | null = null;
|
||||
let usedHuman: string | null = null;
|
||||
let totalHuman: string | null = null;
|
||||
let availableHuman: string | null = null;
|
||||
|
||||
try {
|
||||
const [diskOutHuman, diskOutBytes] = await Promise.all([
|
||||
execCommand(client, "df -h -P / | tail -n +2"),
|
||||
execCommand(client, "df -B1 -P / | tail -n +2"),
|
||||
]);
|
||||
|
||||
const humanLine =
|
||||
diskOutHuman.stdout
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)[0] || "";
|
||||
const bytesLine =
|
||||
diskOutBytes.stdout
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)[0] || "";
|
||||
|
||||
const humanParts = humanLine.split(/\s+/);
|
||||
const bytesParts = bytesLine.split(/\s+/);
|
||||
|
||||
if (humanParts.length >= 6 && bytesParts.length >= 6) {
|
||||
totalHuman = humanParts[1] || null;
|
||||
usedHuman = humanParts[2] || null;
|
||||
availableHuman = humanParts[3] || null;
|
||||
|
||||
const totalBytes = Number(bytesParts[1]);
|
||||
const usedBytes = Number(bytesParts[2]);
|
||||
|
||||
if (
|
||||
Number.isFinite(totalBytes) &&
|
||||
Number.isFinite(usedBytes) &&
|
||||
totalBytes > 0
|
||||
) {
|
||||
diskPercent = Math.max(
|
||||
0,
|
||||
Math.min(100, (usedBytes / totalBytes) * 100),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
diskPercent = null;
|
||||
usedHuman = null;
|
||||
totalHuman = null;
|
||||
availableHuman = null;
|
||||
}
|
||||
|
||||
return {
|
||||
percent: toFixedNum(diskPercent, 0),
|
||||
usedHuman,
|
||||
totalHuman,
|
||||
availableHuman,
|
||||
};
|
||||
}
|
||||
122
src/backend/ssh/widgets/login-stats-collector.ts
Normal file
122
src/backend/ssh/widgets/login-stats-collector.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { Client } from "ssh2";
|
||||
import { execCommand } from "./common-utils.js";
|
||||
|
||||
export interface LoginRecord {
|
||||
user: string;
|
||||
ip: string;
|
||||
time: string;
|
||||
status: "success" | "failed";
|
||||
}
|
||||
|
||||
export interface LoginStats {
|
||||
recentLogins: LoginRecord[];
|
||||
failedLogins: LoginRecord[];
|
||||
totalLogins: number;
|
||||
uniqueIPs: number;
|
||||
}
|
||||
|
||||
export async function collectLoginStats(client: Client): Promise<LoginStats> {
|
||||
const recentLogins: LoginRecord[] = [];
|
||||
const failedLogins: LoginRecord[] = [];
|
||||
const ipSet = new Set<string>();
|
||||
|
||||
try {
|
||||
const lastOut = await execCommand(
|
||||
client,
|
||||
"last -n 20 -F -w | grep -v 'reboot' | grep -v 'wtmp' | head -20",
|
||||
);
|
||||
|
||||
const lastLines = lastOut.stdout
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const line of lastLines) {
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length >= 10) {
|
||||
const user = parts[0];
|
||||
const tty = parts[1];
|
||||
const ip =
|
||||
parts[2] === ":" || parts[2].startsWith(":") ? "local" : parts[2];
|
||||
|
||||
const timeStart = parts.indexOf(
|
||||
parts.find((p) => /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/.test(p)) || "",
|
||||
);
|
||||
if (timeStart > 0 && parts.length > timeStart + 4) {
|
||||
const timeStr = parts.slice(timeStart, timeStart + 5).join(" ");
|
||||
|
||||
if (user && user !== "wtmp" && tty !== "system") {
|
||||
recentLogins.push({
|
||||
user,
|
||||
ip,
|
||||
time: new Date(timeStr).toISOString(),
|
||||
status: "success",
|
||||
});
|
||||
if (ip !== "local") {
|
||||
ipSet.add(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
try {
|
||||
const failedOut = await execCommand(
|
||||
client,
|
||||
"grep 'Failed password' /var/log/auth.log 2>/dev/null | tail -10 || grep 'authentication failure' /var/log/secure 2>/dev/null | tail -10 || echo ''",
|
||||
);
|
||||
|
||||
const failedLines = failedOut.stdout
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const line of failedLines) {
|
||||
let user = "unknown";
|
||||
let ip = "unknown";
|
||||
let timeStr = "";
|
||||
|
||||
const userMatch = line.match(/for (?:invalid user )?(\S+)/);
|
||||
if (userMatch) {
|
||||
user = userMatch[1];
|
||||
}
|
||||
|
||||
const ipMatch = line.match(/from (\d+\.\d+\.\d+\.\d+)/);
|
||||
if (ipMatch) {
|
||||
ip = ipMatch[1];
|
||||
}
|
||||
|
||||
const dateMatch = line.match(/^(\w+\s+\d+\s+\d+:\d+:\d+)/);
|
||||
if (dateMatch) {
|
||||
const currentYear = new Date().getFullYear();
|
||||
timeStr = `${currentYear} ${dateMatch[1]}`;
|
||||
}
|
||||
|
||||
if (user && ip) {
|
||||
failedLogins.push({
|
||||
user,
|
||||
ip,
|
||||
time: timeStr
|
||||
? new Date(timeStr).toISOString()
|
||||
: new Date().toISOString(),
|
||||
status: "failed",
|
||||
});
|
||||
if (ip !== "unknown") {
|
||||
ipSet.add(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
return {
|
||||
recentLogins: recentLogins.slice(0, 10),
|
||||
failedLogins: failedLogins.slice(0, 10),
|
||||
totalLogins: recentLogins.length,
|
||||
uniqueIPs: ipSet.size,
|
||||
};
|
||||
}
|
||||
41
src/backend/ssh/widgets/memory-collector.ts
Normal file
41
src/backend/ssh/widgets/memory-collector.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Client } from "ssh2";
|
||||
import { execCommand, toFixedNum, kibToGiB } from "./common-utils.js";
|
||||
|
||||
export async function collectMemoryMetrics(client: Client): Promise<{
|
||||
percent: number | null;
|
||||
usedGiB: number | null;
|
||||
totalGiB: number | null;
|
||||
}> {
|
||||
let memPercent: number | null = null;
|
||||
let usedGiB: number | null = null;
|
||||
let totalGiB: number | null = null;
|
||||
|
||||
try {
|
||||
const memInfo = await execCommand(client, "cat /proc/meminfo");
|
||||
const lines = memInfo.stdout.split("\n");
|
||||
const getVal = (key: string) => {
|
||||
const line = lines.find((l) => l.startsWith(key));
|
||||
if (!line) return null;
|
||||
const m = line.match(/\d+/);
|
||||
return m ? Number(m[0]) : null;
|
||||
};
|
||||
const totalKb = getVal("MemTotal:");
|
||||
const availKb = getVal("MemAvailable:");
|
||||
if (totalKb && availKb && totalKb > 0) {
|
||||
const usedKb = totalKb - availKb;
|
||||
memPercent = Math.max(0, Math.min(100, (usedKb / totalKb) * 100));
|
||||
usedGiB = kibToGiB(usedKb);
|
||||
totalGiB = kibToGiB(totalKb);
|
||||
}
|
||||
} catch (e) {
|
||||
memPercent = null;
|
||||
usedGiB = null;
|
||||
totalGiB = null;
|
||||
}
|
||||
|
||||
return {
|
||||
percent: toFixedNum(memPercent, 0),
|
||||
usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null,
|
||||
totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null,
|
||||
};
|
||||
}
|
||||
79
src/backend/ssh/widgets/network-collector.ts
Normal file
79
src/backend/ssh/widgets/network-collector.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { Client } from "ssh2";
|
||||
import { execCommand } from "./common-utils.js";
|
||||
import { statsLogger } from "../../utils/logger.js";
|
||||
|
||||
export async function collectNetworkMetrics(client: Client): Promise<{
|
||||
interfaces: Array<{
|
||||
name: string;
|
||||
ip: string;
|
||||
state: string;
|
||||
rxBytes: string | null;
|
||||
txBytes: string | null;
|
||||
}>;
|
||||
}> {
|
||||
const interfaces: Array<{
|
||||
name: string;
|
||||
ip: string;
|
||||
state: string;
|
||||
rxBytes: string | null;
|
||||
txBytes: string | null;
|
||||
}> = [];
|
||||
|
||||
try {
|
||||
const ifconfigOut = await execCommand(
|
||||
client,
|
||||
"ip -o addr show | awk '{print $2,$4}' | grep -v '^lo'",
|
||||
);
|
||||
const netStatOut = await execCommand(
|
||||
client,
|
||||
"ip -o link show | awk '{gsub(/:/, \"\", $2); print $2,$9}'",
|
||||
);
|
||||
|
||||
const addrs = ifconfigOut.stdout
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
const states = netStatOut.stdout
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const ifMap = new Map<string, { ip: string; state: string }>();
|
||||
for (const line of addrs) {
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
const name = parts[0];
|
||||
const ip = parts[1].split("/")[0];
|
||||
if (!ifMap.has(name)) ifMap.set(name, { ip, state: "UNKNOWN" });
|
||||
}
|
||||
}
|
||||
for (const line of states) {
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
const name = parts[0];
|
||||
const state = parts[1];
|
||||
const existing = ifMap.get(name);
|
||||
if (existing) {
|
||||
existing.state = state;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, data] of ifMap.entries()) {
|
||||
interfaces.push({
|
||||
name,
|
||||
ip: data.ip,
|
||||
state: data.state,
|
||||
rxBytes: null,
|
||||
txBytes: null,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
statsLogger.debug("Failed to collect network interface stats", {
|
||||
operation: "network_stats_failed",
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
|
||||
return { interfaces };
|
||||
}
|
||||
63
src/backend/ssh/widgets/processes-collector.ts
Normal file
63
src/backend/ssh/widgets/processes-collector.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { Client } from "ssh2";
|
||||
import { execCommand } from "./common-utils.js";
|
||||
import { statsLogger } from "../../utils/logger.js";
|
||||
|
||||
export async function collectProcessesMetrics(client: Client): Promise<{
|
||||
total: number | null;
|
||||
running: number | null;
|
||||
top: Array<{
|
||||
pid: string;
|
||||
user: string;
|
||||
cpu: string;
|
||||
mem: string;
|
||||
command: string;
|
||||
}>;
|
||||
}> {
|
||||
let totalProcesses: number | null = null;
|
||||
let runningProcesses: number | null = null;
|
||||
const topProcesses: Array<{
|
||||
pid: string;
|
||||
user: string;
|
||||
cpu: string;
|
||||
mem: string;
|
||||
command: string;
|
||||
}> = [];
|
||||
|
||||
try {
|
||||
const psOut = await execCommand(client, "ps aux --sort=-%cpu | head -n 11");
|
||||
const psLines = psOut.stdout
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
if (psLines.length > 1) {
|
||||
for (let i = 1; i < Math.min(psLines.length, 11); i++) {
|
||||
const parts = psLines[i].split(/\s+/);
|
||||
if (parts.length >= 11) {
|
||||
topProcesses.push({
|
||||
pid: parts[1],
|
||||
user: parts[0],
|
||||
cpu: parts[2],
|
||||
mem: parts[3],
|
||||
command: parts.slice(10).join(" ").substring(0, 50),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const procCount = await execCommand(client, "ps aux | wc -l");
|
||||
const runningCount = await execCommand(client, "ps aux | grep -c ' R '");
|
||||
totalProcesses = Number(procCount.stdout.trim()) - 1;
|
||||
runningProcesses = Number(runningCount.stdout.trim());
|
||||
} catch (e) {
|
||||
statsLogger.debug("Failed to collect process stats", {
|
||||
operation: "process_stats_failed",
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
total: totalProcesses,
|
||||
running: runningProcesses,
|
||||
top: topProcesses,
|
||||
};
|
||||
}
|
||||
37
src/backend/ssh/widgets/system-collector.ts
Normal file
37
src/backend/ssh/widgets/system-collector.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { Client } from "ssh2";
|
||||
import { execCommand } from "./common-utils.js";
|
||||
import { statsLogger } from "../../utils/logger.js";
|
||||
|
||||
export async function collectSystemMetrics(client: Client): Promise<{
|
||||
hostname: string | null;
|
||||
kernel: string | null;
|
||||
os: string | null;
|
||||
}> {
|
||||
let hostname: string | null = null;
|
||||
let kernel: string | null = null;
|
||||
let os: string | null = null;
|
||||
|
||||
try {
|
||||
const hostnameOut = await execCommand(client, "hostname");
|
||||
const kernelOut = await execCommand(client, "uname -r");
|
||||
const osOut = await execCommand(
|
||||
client,
|
||||
"cat /etc/os-release | grep '^PRETTY_NAME=' | cut -d'\"' -f2",
|
||||
);
|
||||
|
||||
hostname = hostnameOut.stdout.trim() || null;
|
||||
kernel = kernelOut.stdout.trim() || null;
|
||||
os = osOut.stdout.trim() || null;
|
||||
} catch (e) {
|
||||
statsLogger.debug("Failed to collect system info", {
|
||||
operation: "system_info_failed",
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
hostname,
|
||||
kernel,
|
||||
os,
|
||||
};
|
||||
}
|
||||
35
src/backend/ssh/widgets/uptime-collector.ts
Normal file
35
src/backend/ssh/widgets/uptime-collector.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { Client } from "ssh2";
|
||||
import { execCommand } from "./common-utils.js";
|
||||
import { statsLogger } from "../../utils/logger.js";
|
||||
|
||||
export async function collectUptimeMetrics(client: Client): Promise<{
|
||||
seconds: number | null;
|
||||
formatted: string | null;
|
||||
}> {
|
||||
let uptimeSeconds: number | null = null;
|
||||
let uptimeFormatted: string | null = null;
|
||||
|
||||
try {
|
||||
const uptimeOut = await execCommand(client, "cat /proc/uptime");
|
||||
const uptimeParts = uptimeOut.stdout.trim().split(/\s+/);
|
||||
if (uptimeParts.length >= 1) {
|
||||
uptimeSeconds = Number(uptimeParts[0]);
|
||||
if (Number.isFinite(uptimeSeconds)) {
|
||||
const days = Math.floor(uptimeSeconds / 86400);
|
||||
const hours = Math.floor((uptimeSeconds % 86400) / 3600);
|
||||
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
|
||||
uptimeFormatted = `${days}d ${hours}h ${minutes}m`;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
statsLogger.debug("Failed to collect uptime", {
|
||||
operation: "uptime_failed",
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
seconds: uptimeSeconds,
|
||||
formatted: uptimeFormatted,
|
||||
};
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
|
||||
if (persistentConfig.parsed) {
|
||||
Object.assign(process.env, persistentConfig.parsed);
|
||||
}
|
||||
} catch {}
|
||||
} catch (error) {}
|
||||
|
||||
let version = "unknown";
|
||||
|
||||
@@ -104,6 +104,19 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
|
||||
await import("./ssh/server-stats.js");
|
||||
await import("./dashboard.js");
|
||||
|
||||
// Initialize Guacamole server for RDP/VNC/Telnet support
|
||||
if (process.env.ENABLE_GUACAMOLE !== "false") {
|
||||
try {
|
||||
await import("./guacamole/guacamole-server.js");
|
||||
systemLogger.info("Guacamole server initialized", { operation: "guac_init" });
|
||||
} catch (error) {
|
||||
systemLogger.warn("Failed to initialize Guacamole server (guacd may not be available)", {
|
||||
operation: "guac_init_skip",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
systemLogger.info(
|
||||
"Received SIGINT signal, initiating graceful shutdown...",
|
||||
|
||||
@@ -85,24 +85,25 @@ class AuthManager {
|
||||
await this.userCrypto.setupUserEncryption(userId, password);
|
||||
}
|
||||
|
||||
async registerOIDCUser(userId: string): Promise<void> {
|
||||
await this.userCrypto.setupOIDCUserEncryption(userId);
|
||||
async registerOIDCUser(
|
||||
userId: string,
|
||||
sessionDurationMs: number,
|
||||
): Promise<void> {
|
||||
await this.userCrypto.setupOIDCUserEncryption(userId, sessionDurationMs);
|
||||
}
|
||||
|
||||
async authenticateOIDCUser(userId: string): Promise<boolean> {
|
||||
const authenticated = await this.userCrypto.authenticateOIDCUser(userId);
|
||||
async authenticateOIDCUser(
|
||||
userId: string,
|
||||
deviceType?: DeviceType,
|
||||
): Promise<boolean> {
|
||||
const sessionDurationMs =
|
||||
deviceType === "desktop" || deviceType === "mobile"
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
: 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
if (authenticated) {
|
||||
await this.performLazyEncryptionMigration(userId);
|
||||
}
|
||||
|
||||
return authenticated;
|
||||
}
|
||||
|
||||
async authenticateUser(userId: string, password: string): Promise<boolean> {
|
||||
const authenticated = await this.userCrypto.authenticateUser(
|
||||
const authenticated = await this.userCrypto.authenticateOIDCUser(
|
||||
userId,
|
||||
password,
|
||||
sessionDurationMs,
|
||||
);
|
||||
|
||||
if (authenticated) {
|
||||
@@ -112,6 +113,33 @@ class AuthManager {
|
||||
return authenticated;
|
||||
}
|
||||
|
||||
async authenticateUser(
|
||||
userId: string,
|
||||
password: string,
|
||||
deviceType?: DeviceType,
|
||||
): Promise<boolean> {
|
||||
const sessionDurationMs =
|
||||
deviceType === "desktop" || deviceType === "mobile"
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
: 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const authenticated = await this.userCrypto.authenticateUser(
|
||||
userId,
|
||||
password,
|
||||
sessionDurationMs,
|
||||
);
|
||||
|
||||
if (authenticated) {
|
||||
await this.performLazyEncryptionMigration(userId);
|
||||
}
|
||||
|
||||
return authenticated;
|
||||
}
|
||||
|
||||
async convertToOIDCEncryption(userId: string): Promise<void> {
|
||||
await this.userCrypto.convertToOIDCEncryption(userId);
|
||||
}
|
||||
|
||||
private async performLazyEncryptionMigration(userId: string): Promise<void> {
|
||||
try {
|
||||
const userDataKey = this.getUserDataKey(userId);
|
||||
|
||||
@@ -233,7 +233,7 @@ IP.3 = 0.0.0.0
|
||||
let envContent = "";
|
||||
try {
|
||||
envContent = await fs.readFile(this.ENV_FILE, "utf8");
|
||||
} catch {}
|
||||
} catch (error) {}
|
||||
|
||||
let updatedContent = envContent;
|
||||
let hasChanges = false;
|
||||
|
||||
@@ -12,6 +12,7 @@ interface EncryptedFileMetadata {
|
||||
algorithm: string;
|
||||
keySource?: string;
|
||||
salt?: string;
|
||||
dataSize?: number;
|
||||
}
|
||||
|
||||
class DatabaseFileEncryption {
|
||||
@@ -25,11 +26,12 @@ class DatabaseFileEncryption {
|
||||
buffer: Buffer,
|
||||
targetPath: string,
|
||||
): Promise<string> {
|
||||
const tmpPath = `${targetPath}.tmp-${Date.now()}-${process.pid}`;
|
||||
const metadataPath = `${targetPath}${this.METADATA_FILE_SUFFIX}`;
|
||||
|
||||
try {
|
||||
const key = await this.systemCrypto.getDatabaseKey();
|
||||
|
||||
const iv = crypto.randomBytes(16);
|
||||
|
||||
const cipher = crypto.createCipheriv(
|
||||
this.ALGORITHM,
|
||||
key,
|
||||
@@ -45,14 +47,55 @@ class DatabaseFileEncryption {
|
||||
fingerprint: "termix-v2-systemcrypto",
|
||||
algorithm: this.ALGORITHM,
|
||||
keySource: "SystemCrypto",
|
||||
dataSize: encrypted.length,
|
||||
};
|
||||
|
||||
const metadataPath = `${targetPath}${this.METADATA_FILE_SUFFIX}`;
|
||||
fs.writeFileSync(targetPath, encrypted);
|
||||
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
|
||||
const metadataJson = JSON.stringify(metadata, null, 2);
|
||||
const metadataBuffer = Buffer.from(metadataJson, "utf8");
|
||||
const metadataLengthBuffer = Buffer.alloc(4);
|
||||
metadataLengthBuffer.writeUInt32BE(metadataBuffer.length, 0);
|
||||
|
||||
const finalBuffer = Buffer.concat([
|
||||
metadataLengthBuffer,
|
||||
metadataBuffer,
|
||||
encrypted,
|
||||
]);
|
||||
|
||||
fs.writeFileSync(tmpPath, finalBuffer);
|
||||
fs.renameSync(tmpPath, targetPath);
|
||||
|
||||
try {
|
||||
if (fs.existsSync(metadataPath)) {
|
||||
fs.unlinkSync(metadataPath);
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
databaseLogger.warn("Failed to cleanup old metadata file", {
|
||||
operation: "old_meta_cleanup_failed",
|
||||
path: metadataPath,
|
||||
error:
|
||||
cleanupError instanceof Error
|
||||
? cleanupError.message
|
||||
: "Unknown error",
|
||||
});
|
||||
}
|
||||
|
||||
return targetPath;
|
||||
} catch (error) {
|
||||
try {
|
||||
if (fs.existsSync(tmpPath)) {
|
||||
fs.unlinkSync(tmpPath);
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
databaseLogger.warn("Failed to cleanup temporary files", {
|
||||
operation: "temp_file_cleanup_failed",
|
||||
tmpPath,
|
||||
error:
|
||||
cleanupError instanceof Error
|
||||
? cleanupError.message
|
||||
: "Unknown error",
|
||||
});
|
||||
}
|
||||
|
||||
databaseLogger.error("Failed to encrypt database buffer", error, {
|
||||
operation: "database_buffer_encryption_failed",
|
||||
targetPath,
|
||||
@@ -74,6 +117,8 @@ class DatabaseFileEncryption {
|
||||
const encryptedPath =
|
||||
targetPath || `${sourcePath}${this.ENCRYPTED_FILE_SUFFIX}`;
|
||||
const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`;
|
||||
const tmpPath = `${encryptedPath}.tmp-${Date.now()}-${process.pid}`;
|
||||
const tmpMetadataPath = `${tmpPath}${this.METADATA_FILE_SUFFIX}`;
|
||||
|
||||
try {
|
||||
const sourceData = fs.readFileSync(sourcePath);
|
||||
@@ -93,6 +138,12 @@ class DatabaseFileEncryption {
|
||||
]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
const keyFingerprint = crypto
|
||||
.createHash("sha256")
|
||||
.update(key)
|
||||
.digest("hex")
|
||||
.substring(0, 16);
|
||||
|
||||
const metadata: EncryptedFileMetadata = {
|
||||
iv: iv.toString("hex"),
|
||||
tag: tag.toString("hex"),
|
||||
@@ -100,10 +151,14 @@ class DatabaseFileEncryption {
|
||||
fingerprint: "termix-v2-systemcrypto",
|
||||
algorithm: this.ALGORITHM,
|
||||
keySource: "SystemCrypto",
|
||||
dataSize: encrypted.length,
|
||||
};
|
||||
|
||||
fs.writeFileSync(encryptedPath, encrypted);
|
||||
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
|
||||
fs.writeFileSync(tmpPath, encrypted);
|
||||
fs.writeFileSync(tmpMetadataPath, JSON.stringify(metadata, null, 2));
|
||||
|
||||
fs.renameSync(tmpPath, encryptedPath);
|
||||
fs.renameSync(tmpMetadataPath, metadataPath);
|
||||
|
||||
databaseLogger.info("Database file encrypted successfully", {
|
||||
operation: "database_file_encryption",
|
||||
@@ -111,11 +166,30 @@ class DatabaseFileEncryption {
|
||||
encryptedPath,
|
||||
fileSize: sourceData.length,
|
||||
encryptedSize: encrypted.length,
|
||||
keyFingerprint,
|
||||
fingerprintPrefix: metadata.fingerprint,
|
||||
});
|
||||
|
||||
return encryptedPath;
|
||||
} catch (error) {
|
||||
try {
|
||||
if (fs.existsSync(tmpPath)) {
|
||||
fs.unlinkSync(tmpPath);
|
||||
}
|
||||
if (fs.existsSync(tmpMetadataPath)) {
|
||||
fs.unlinkSync(tmpMetadataPath);
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
databaseLogger.warn("Failed to cleanup temporary files", {
|
||||
operation: "temp_file_cleanup_failed",
|
||||
tmpPath,
|
||||
error:
|
||||
cleanupError instanceof Error
|
||||
? cleanupError.message
|
||||
: "Unknown error",
|
||||
});
|
||||
}
|
||||
|
||||
databaseLogger.error("Failed to encrypt database file", error, {
|
||||
operation: "database_file_encryption_failed",
|
||||
sourcePath,
|
||||
@@ -134,16 +208,69 @@ class DatabaseFileEncryption {
|
||||
);
|
||||
}
|
||||
|
||||
const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`;
|
||||
if (!fs.existsSync(metadataPath)) {
|
||||
throw new Error(`Metadata file does not exist: ${metadataPath}`);
|
||||
let metadata: EncryptedFileMetadata;
|
||||
let encryptedData: Buffer;
|
||||
|
||||
const fileBuffer = fs.readFileSync(encryptedPath);
|
||||
|
||||
try {
|
||||
const metadataLength = fileBuffer.readUInt32BE(0);
|
||||
const metadataEnd = 4 + metadataLength;
|
||||
|
||||
if (
|
||||
metadataLength <= 0 ||
|
||||
metadataEnd > fileBuffer.length ||
|
||||
metadataEnd <= 4
|
||||
) {
|
||||
throw new Error("Invalid metadata length in single-file format");
|
||||
}
|
||||
|
||||
const metadataJson = fileBuffer.slice(4, metadataEnd).toString("utf8");
|
||||
metadata = JSON.parse(metadataJson);
|
||||
encryptedData = fileBuffer.slice(metadataEnd);
|
||||
|
||||
if (!metadata.iv || !metadata.tag || !metadata.version) {
|
||||
throw new Error("Invalid metadata structure in single-file format");
|
||||
}
|
||||
} catch (singleFileError) {
|
||||
const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`;
|
||||
if (!fs.existsSync(metadataPath)) {
|
||||
throw new Error(
|
||||
`Could not read database: Not a valid single-file format and metadata file is missing: ${metadataPath}. Error: ${singleFileError.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const metadataContent = fs.readFileSync(metadataPath, "utf8");
|
||||
metadata = JSON.parse(metadataContent);
|
||||
encryptedData = fileBuffer;
|
||||
} catch (twoFileError) {
|
||||
throw new Error(
|
||||
`Failed to read database using both single-file and two-file formats. Error: ${twoFileError.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const metadataContent = fs.readFileSync(metadataPath, "utf8");
|
||||
const metadata: EncryptedFileMetadata = JSON.parse(metadataContent);
|
||||
|
||||
const encryptedData = fs.readFileSync(encryptedPath);
|
||||
if (
|
||||
metadata.dataSize !== undefined &&
|
||||
encryptedData.length !== metadata.dataSize
|
||||
) {
|
||||
databaseLogger.error(
|
||||
"Encrypted file size mismatch - possible corrupted write or mismatched metadata",
|
||||
null,
|
||||
{
|
||||
operation: "database_file_size_mismatch",
|
||||
encryptedPath,
|
||||
actualSize: encryptedData.length,
|
||||
expectedSize: metadata.dataSize,
|
||||
},
|
||||
);
|
||||
throw new Error(
|
||||
`Encrypted file size mismatch: expected ${metadata.dataSize} bytes but got ${encryptedData.length} bytes. ` +
|
||||
`This indicates corrupted files or interrupted write operation.`,
|
||||
);
|
||||
}
|
||||
|
||||
let key: Buffer;
|
||||
if (metadata.version === "v2") {
|
||||
@@ -181,13 +308,67 @@ class DatabaseFileEncryption {
|
||||
|
||||
return decryptedBuffer;
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
const isAuthError =
|
||||
errorMessage.includes("Unsupported state") ||
|
||||
errorMessage.includes("authenticate data") ||
|
||||
errorMessage.includes("auth");
|
||||
|
||||
if (isAuthError) {
|
||||
const dataDir = process.env.DATA_DIR || "./db/data";
|
||||
const envPath = path.join(dataDir, ".env");
|
||||
|
||||
let envFileExists = false;
|
||||
let envFileReadable = false;
|
||||
try {
|
||||
envFileExists = fs.existsSync(envPath);
|
||||
if (envFileExists) {
|
||||
fs.accessSync(envPath, fs.constants.R_OK);
|
||||
envFileReadable = true;
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.debug("Operation failed, continuing", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
databaseLogger.error(
|
||||
"Database decryption authentication failed - possible causes: wrong DATABASE_KEY, corrupted files, or interrupted write",
|
||||
error,
|
||||
{
|
||||
operation: "database_buffer_decryption_auth_failed",
|
||||
encryptedPath,
|
||||
dataDir,
|
||||
envPath,
|
||||
envFileExists,
|
||||
envFileReadable,
|
||||
hasEnvKey: !!process.env.DATABASE_KEY,
|
||||
envKeyLength: process.env.DATABASE_KEY?.length || 0,
|
||||
suggestion:
|
||||
"Check if DATABASE_KEY in .env matches the key used for encryption",
|
||||
},
|
||||
);
|
||||
throw new Error(
|
||||
`Database decryption authentication failed. This usually means:\n` +
|
||||
`1. DATABASE_KEY has changed or is missing from ${dataDir}/.env\n` +
|
||||
`2. Encrypted file was corrupted during write (system crash/restart)\n` +
|
||||
`3. Metadata file does not match encrypted data\n` +
|
||||
`\nDebug info:\n` +
|
||||
`- DATA_DIR: ${dataDir}\n` +
|
||||
`- .env file exists: ${envFileExists}\n` +
|
||||
`- .env file readable: ${envFileReadable}\n` +
|
||||
`- DATABASE_KEY in environment: ${!!process.env.DATABASE_KEY}\n` +
|
||||
`Original error: ${errorMessage}`,
|
||||
);
|
||||
}
|
||||
|
||||
databaseLogger.error("Failed to decrypt database to buffer", error, {
|
||||
operation: "database_buffer_decryption_failed",
|
||||
encryptedPath,
|
||||
errorMessage,
|
||||
});
|
||||
throw new Error(
|
||||
`Database buffer decryption failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
throw new Error(`Database buffer decryption failed: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +396,26 @@ class DatabaseFileEncryption {
|
||||
|
||||
const encryptedData = fs.readFileSync(encryptedPath);
|
||||
|
||||
if (
|
||||
metadata.dataSize !== undefined &&
|
||||
encryptedData.length !== metadata.dataSize
|
||||
) {
|
||||
databaseLogger.error(
|
||||
"Encrypted file size mismatch - possible corrupted write or mismatched metadata",
|
||||
null,
|
||||
{
|
||||
operation: "database_file_size_mismatch",
|
||||
encryptedPath,
|
||||
actualSize: encryptedData.length,
|
||||
expectedSize: metadata.dataSize,
|
||||
},
|
||||
);
|
||||
throw new Error(
|
||||
`Encrypted file size mismatch: expected ${metadata.dataSize} bytes but got ${encryptedData.length} bytes. ` +
|
||||
`This indicates corrupted files or interrupted write operation.`,
|
||||
);
|
||||
}
|
||||
|
||||
let key: Buffer;
|
||||
if (metadata.version === "v2") {
|
||||
key = await this.systemCrypto.getDatabaseKey();
|
||||
@@ -274,18 +475,43 @@ class DatabaseFileEncryption {
|
||||
}
|
||||
|
||||
static isEncryptedDatabaseFile(filePath: string): boolean {
|
||||
const metadataPath = `${filePath}${this.METADATA_FILE_SUFFIX}`;
|
||||
|
||||
if (!fs.existsSync(filePath) || !fs.existsSync(metadataPath)) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const metadataPath = `${filePath}${this.METADATA_FILE_SUFFIX}`;
|
||||
if (fs.existsSync(metadataPath)) {
|
||||
try {
|
||||
const metadataContent = fs.readFileSync(metadataPath, "utf8");
|
||||
const metadata: EncryptedFileMetadata = JSON.parse(metadataContent);
|
||||
return (
|
||||
metadata.version === this.VERSION &&
|
||||
metadata.algorithm === this.ALGORITHM
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const metadataContent = fs.readFileSync(metadataPath, "utf8");
|
||||
const metadata: EncryptedFileMetadata = JSON.parse(metadataContent);
|
||||
const fileBuffer = fs.readFileSync(filePath);
|
||||
if (fileBuffer.length < 4) return false;
|
||||
|
||||
const metadataLength = fileBuffer.readUInt32BE(0);
|
||||
const metadataEnd = 4 + metadataLength;
|
||||
|
||||
if (metadataLength <= 0 || metadataEnd > fileBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const metadataJson = fileBuffer.slice(4, metadataEnd).toString("utf8");
|
||||
const metadata: EncryptedFileMetadata = JSON.parse(metadataJson);
|
||||
|
||||
return (
|
||||
metadata.version === this.VERSION &&
|
||||
metadata.algorithm === this.ALGORITHM
|
||||
metadata.algorithm === this.ALGORITHM &&
|
||||
!!metadata.iv &&
|
||||
!!metadata.tag
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
@@ -322,6 +548,125 @@ class DatabaseFileEncryption {
|
||||
}
|
||||
}
|
||||
|
||||
static getDiagnosticInfo(encryptedPath: string): {
|
||||
dataFile: {
|
||||
exists: boolean;
|
||||
size?: number;
|
||||
mtime?: string;
|
||||
readable?: boolean;
|
||||
};
|
||||
metadataFile: {
|
||||
exists: boolean;
|
||||
size?: number;
|
||||
mtime?: string;
|
||||
readable?: boolean;
|
||||
content?: EncryptedFileMetadata;
|
||||
};
|
||||
environment: {
|
||||
dataDir: string;
|
||||
envPath: string;
|
||||
envFileExists: boolean;
|
||||
envFileReadable: boolean;
|
||||
hasEnvKey: boolean;
|
||||
envKeyLength: number;
|
||||
};
|
||||
validation: {
|
||||
filesConsistent: boolean;
|
||||
sizeMismatch?: boolean;
|
||||
expectedSize?: number;
|
||||
actualSize?: number;
|
||||
};
|
||||
} {
|
||||
const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`;
|
||||
const dataDir = process.env.DATA_DIR || "./db/data";
|
||||
const envPath = path.join(dataDir, ".env");
|
||||
|
||||
const result: ReturnType<typeof this.getDiagnosticInfo> = {
|
||||
dataFile: { exists: false },
|
||||
metadataFile: { exists: false },
|
||||
environment: {
|
||||
dataDir,
|
||||
envPath,
|
||||
envFileExists: false,
|
||||
envFileReadable: false,
|
||||
hasEnvKey: !!process.env.DATABASE_KEY,
|
||||
envKeyLength: process.env.DATABASE_KEY?.length || 0,
|
||||
},
|
||||
validation: {
|
||||
filesConsistent: false,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
result.dataFile.exists = fs.existsSync(encryptedPath);
|
||||
if (result.dataFile.exists) {
|
||||
try {
|
||||
fs.accessSync(encryptedPath, fs.constants.R_OK);
|
||||
result.dataFile.readable = true;
|
||||
const stats = fs.statSync(encryptedPath);
|
||||
result.dataFile.size = stats.size;
|
||||
result.dataFile.mtime = stats.mtime.toISOString();
|
||||
} catch {
|
||||
result.dataFile.readable = false;
|
||||
}
|
||||
}
|
||||
|
||||
result.metadataFile.exists = fs.existsSync(metadataPath);
|
||||
if (result.metadataFile.exists) {
|
||||
try {
|
||||
fs.accessSync(metadataPath, fs.constants.R_OK);
|
||||
result.metadataFile.readable = true;
|
||||
const stats = fs.statSync(metadataPath);
|
||||
result.metadataFile.size = stats.size;
|
||||
result.metadataFile.mtime = stats.mtime.toISOString();
|
||||
|
||||
const content = fs.readFileSync(metadataPath, "utf8");
|
||||
result.metadataFile.content = JSON.parse(content);
|
||||
} catch {
|
||||
result.metadataFile.readable = false;
|
||||
}
|
||||
}
|
||||
|
||||
result.environment.envFileExists = fs.existsSync(envPath);
|
||||
if (result.environment.envFileExists) {
|
||||
try {
|
||||
fs.accessSync(envPath, fs.constants.R_OK);
|
||||
result.environment.envFileReadable = true;
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
if (
|
||||
result.dataFile.exists &&
|
||||
result.metadataFile.exists &&
|
||||
result.metadataFile.content
|
||||
) {
|
||||
result.validation.filesConsistent = true;
|
||||
|
||||
if (result.metadataFile.content.dataSize !== undefined) {
|
||||
result.validation.expectedSize = result.metadataFile.content.dataSize;
|
||||
result.validation.actualSize = result.dataFile.size;
|
||||
result.validation.sizeMismatch =
|
||||
result.metadataFile.content.dataSize !== result.dataFile.size;
|
||||
if (result.validation.sizeMismatch) {
|
||||
result.validation.filesConsistent = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to generate diagnostic info", error, {
|
||||
operation: "diagnostic_info_failed",
|
||||
encryptedPath,
|
||||
});
|
||||
}
|
||||
|
||||
databaseLogger.info("Database encryption diagnostic info", {
|
||||
operation: "diagnostic_info_generated",
|
||||
...result,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static async createEncryptedBackup(
|
||||
databasePath: string,
|
||||
backupDir: string,
|
||||
|
||||
@@ -82,7 +82,7 @@ export class LazyFieldEncryption {
|
||||
legacyFieldName,
|
||||
);
|
||||
return decrypted;
|
||||
} catch {}
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
const sensitiveFields = [
|
||||
@@ -174,7 +174,7 @@ export class LazyFieldEncryption {
|
||||
wasPlaintext: false,
|
||||
wasLegacyEncryption: true,
|
||||
};
|
||||
} catch {}
|
||||
} catch (error) {}
|
||||
}
|
||||
return {
|
||||
encrypted: fieldValue,
|
||||
|
||||
@@ -254,5 +254,6 @@ export const authLogger = new Logger("AUTH", "🔐", "#ef4444");
|
||||
export const systemLogger = new Logger("SYSTEM", "🚀", "#14b8a6");
|
||||
export const versionLogger = new Logger("VERSION", "📦", "#8b5cf6");
|
||||
export const dashboardLogger = new Logger("DASHBOARD", "📊", "#ec4899");
|
||||
export const guacLogger = new Logger("GUACAMOLE", "🖼️", "#ff6b6b");
|
||||
|
||||
export const logger = systemLogger;
|
||||
|
||||
146
src/backend/utils/login-rate-limiter.ts
Normal file
146
src/backend/utils/login-rate-limiter.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
interface LoginAttempt {
|
||||
count: number;
|
||||
firstAttempt: number;
|
||||
lockedUntil?: number;
|
||||
}
|
||||
|
||||
class LoginRateLimiter {
|
||||
private ipAttempts = new Map<string, LoginAttempt>();
|
||||
private usernameAttempts = new Map<string, LoginAttempt>();
|
||||
|
||||
private readonly MAX_ATTEMPTS = 5;
|
||||
private readonly WINDOW_MS = 10 * 60 * 1000;
|
||||
private readonly LOCKOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
constructor() {
|
||||
setInterval(() => this.cleanup(), 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
const now = Date.now();
|
||||
|
||||
for (const [ip, attempt] of this.ipAttempts.entries()) {
|
||||
if (attempt.lockedUntil && attempt.lockedUntil < now) {
|
||||
this.ipAttempts.delete(ip);
|
||||
} else if (
|
||||
!attempt.lockedUntil &&
|
||||
now - attempt.firstAttempt > this.WINDOW_MS
|
||||
) {
|
||||
this.ipAttempts.delete(ip);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [username, attempt] of this.usernameAttempts.entries()) {
|
||||
if (attempt.lockedUntil && attempt.lockedUntil < now) {
|
||||
this.usernameAttempts.delete(username);
|
||||
} else if (
|
||||
!attempt.lockedUntil &&
|
||||
now - attempt.firstAttempt > this.WINDOW_MS
|
||||
) {
|
||||
this.usernameAttempts.delete(username);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
recordFailedAttempt(ip: string, username?: string): void {
|
||||
const now = Date.now();
|
||||
|
||||
const ipAttempt = this.ipAttempts.get(ip);
|
||||
if (!ipAttempt) {
|
||||
this.ipAttempts.set(ip, {
|
||||
count: 1,
|
||||
firstAttempt: now,
|
||||
});
|
||||
} else if (now - ipAttempt.firstAttempt > this.WINDOW_MS) {
|
||||
this.ipAttempts.set(ip, {
|
||||
count: 1,
|
||||
firstAttempt: now,
|
||||
});
|
||||
} else {
|
||||
ipAttempt.count++;
|
||||
if (ipAttempt.count >= this.MAX_ATTEMPTS) {
|
||||
ipAttempt.lockedUntil = now + this.LOCKOUT_MS;
|
||||
}
|
||||
}
|
||||
|
||||
if (username) {
|
||||
const userAttempt = this.usernameAttempts.get(username);
|
||||
if (!userAttempt) {
|
||||
this.usernameAttempts.set(username, {
|
||||
count: 1,
|
||||
firstAttempt: now,
|
||||
});
|
||||
} else if (now - userAttempt.firstAttempt > this.WINDOW_MS) {
|
||||
this.usernameAttempts.set(username, {
|
||||
count: 1,
|
||||
firstAttempt: now,
|
||||
});
|
||||
} else {
|
||||
userAttempt.count++;
|
||||
if (userAttempt.count >= this.MAX_ATTEMPTS) {
|
||||
userAttempt.lockedUntil = now + this.LOCKOUT_MS;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resetAttempts(ip: string, username?: string): void {
|
||||
this.ipAttempts.delete(ip);
|
||||
if (username) {
|
||||
this.usernameAttempts.delete(username);
|
||||
}
|
||||
}
|
||||
|
||||
isLocked(
|
||||
ip: string,
|
||||
username?: string,
|
||||
): { locked: boolean; remainingTime?: number } {
|
||||
const now = Date.now();
|
||||
|
||||
const ipAttempt = this.ipAttempts.get(ip);
|
||||
if (ipAttempt?.lockedUntil && ipAttempt.lockedUntil > now) {
|
||||
return {
|
||||
locked: true,
|
||||
remainingTime: Math.ceil((ipAttempt.lockedUntil - now) / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
if (username) {
|
||||
const userAttempt = this.usernameAttempts.get(username);
|
||||
if (userAttempt?.lockedUntil && userAttempt.lockedUntil > now) {
|
||||
return {
|
||||
locked: true,
|
||||
remainingTime: Math.ceil((userAttempt.lockedUntil - now) / 1000),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { locked: false };
|
||||
}
|
||||
|
||||
getRemainingAttempts(ip: string, username?: string): number {
|
||||
const now = Date.now();
|
||||
let minRemaining = this.MAX_ATTEMPTS;
|
||||
|
||||
const ipAttempt = this.ipAttempts.get(ip);
|
||||
if (ipAttempt && now - ipAttempt.firstAttempt <= this.WINDOW_MS) {
|
||||
const ipRemaining = Math.max(0, this.MAX_ATTEMPTS - ipAttempt.count);
|
||||
minRemaining = Math.min(minRemaining, ipRemaining);
|
||||
}
|
||||
|
||||
if (username) {
|
||||
const userAttempt = this.usernameAttempts.get(username);
|
||||
if (userAttempt && now - userAttempt.firstAttempt <= this.WINDOW_MS) {
|
||||
const userRemaining = Math.max(
|
||||
0,
|
||||
this.MAX_ATTEMPTS - userAttempt.count,
|
||||
);
|
||||
minRemaining = Math.min(minRemaining, userRemaining);
|
||||
}
|
||||
}
|
||||
|
||||
return minRemaining;
|
||||
}
|
||||
}
|
||||
|
||||
export const loginRateLimiter = new LoginRateLimiter();
|
||||
@@ -1,4 +1,5 @@
|
||||
import ssh2Pkg from "ssh2";
|
||||
import { sshLogger } from "./logger.js";
|
||||
const ssh2Utils = ssh2Pkg.utils;
|
||||
|
||||
function detectKeyTypeFromContent(keyContent: string): string {
|
||||
@@ -84,7 +85,7 @@ function detectKeyTypeFromContent(keyContent: string): string {
|
||||
} else if (decodedString.includes("1.3.101.112")) {
|
||||
return "ssh-ed25519";
|
||||
}
|
||||
} catch {}
|
||||
} catch (error) {}
|
||||
|
||||
if (content.length < 800) {
|
||||
return "ssh-ed25519";
|
||||
@@ -140,7 +141,7 @@ function detectPublicKeyTypeFromContent(publicKeyContent: string): string {
|
||||
} else if (decodedString.includes("1.3.101.112")) {
|
||||
return "ssh-ed25519";
|
||||
}
|
||||
} catch {}
|
||||
} catch (error) {}
|
||||
|
||||
if (content.length < 400) {
|
||||
return "ssh-ed25519";
|
||||
@@ -242,7 +243,7 @@ export function parseSSHKey(
|
||||
|
||||
useSSH2 = true;
|
||||
}
|
||||
} catch {}
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
if (!useSSH2) {
|
||||
@@ -268,7 +269,7 @@ export function parseSSHKey(
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
} catch {}
|
||||
} catch (error) {}
|
||||
|
||||
return {
|
||||
privateKey: privateKeyData,
|
||||
|
||||
@@ -51,17 +51,8 @@ class SystemCrypto {
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (fileError) {
|
||||
databaseLogger.warn("Failed to read .env file for JWT secret", {
|
||||
operation: "jwt_init_file_read_failed",
|
||||
error:
|
||||
fileError instanceof Error ? fileError.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
} catch (fileError) {}
|
||||
|
||||
databaseLogger.warn("Generating new JWT secret", {
|
||||
operation: "jwt_generating_new_secret",
|
||||
});
|
||||
await this.generateAndGuideUser();
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to initialize JWT secret", error, {
|
||||
@@ -80,29 +71,44 @@ class SystemCrypto {
|
||||
|
||||
async initializeDatabaseKey(): Promise<void> {
|
||||
try {
|
||||
const dataDir = process.env.DATA_DIR || "./db/data";
|
||||
const envPath = path.join(dataDir, ".env");
|
||||
|
||||
const envKey = process.env.DATABASE_KEY;
|
||||
if (envKey && envKey.length >= 64) {
|
||||
this.databaseKey = Buffer.from(envKey, "hex");
|
||||
const keyFingerprint = crypto
|
||||
.createHash("sha256")
|
||||
.update(this.databaseKey)
|
||||
.digest("hex")
|
||||
.substring(0, 16);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const dataDir = process.env.DATA_DIR || "./db/data";
|
||||
const envPath = path.join(dataDir, ".env");
|
||||
|
||||
try {
|
||||
const envContent = await fs.readFile(envPath, "utf8");
|
||||
const dbKeyMatch = envContent.match(/^DATABASE_KEY=(.+)$/m);
|
||||
if (dbKeyMatch && dbKeyMatch[1] && dbKeyMatch[1].length >= 64) {
|
||||
this.databaseKey = Buffer.from(dbKeyMatch[1], "hex");
|
||||
process.env.DATABASE_KEY = dbKeyMatch[1];
|
||||
|
||||
const keyFingerprint = crypto
|
||||
.createHash("sha256")
|
||||
.update(this.databaseKey)
|
||||
.digest("hex")
|
||||
.substring(0, 16);
|
||||
|
||||
return;
|
||||
} else {
|
||||
}
|
||||
} catch {}
|
||||
} catch (fileError) {}
|
||||
|
||||
await this.generateAndGuideDatabaseKey();
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to initialize database key", error, {
|
||||
operation: "db_key_init_failed",
|
||||
dataDir: process.env.DATA_DIR || "./db/data",
|
||||
});
|
||||
throw new Error("Database key initialization failed");
|
||||
}
|
||||
@@ -134,7 +140,7 @@ class SystemCrypto {
|
||||
process.env.INTERNAL_AUTH_TOKEN = tokenMatch[1];
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
} catch (error) {}
|
||||
|
||||
await this.generateAndGuideInternalAuthToken();
|
||||
} catch (error) {
|
||||
|
||||
@@ -21,8 +21,8 @@ interface EncryptedDEK {
|
||||
|
||||
interface UserSession {
|
||||
dataKey: Buffer;
|
||||
lastActivity: number;
|
||||
expiresAt: number;
|
||||
lastActivity?: number;
|
||||
}
|
||||
|
||||
class UserCrypto {
|
||||
@@ -33,8 +33,6 @@ class UserCrypto {
|
||||
private static readonly PBKDF2_ITERATIONS = 100000;
|
||||
private static readonly KEK_LENGTH = 32;
|
||||
private static readonly DEK_LENGTH = 32;
|
||||
private static readonly SESSION_DURATION = 24 * 60 * 60 * 1000;
|
||||
private static readonly MAX_INACTIVITY = 6 * 60 * 60 * 1000;
|
||||
|
||||
private constructor() {
|
||||
setInterval(
|
||||
@@ -69,7 +67,10 @@ class UserCrypto {
|
||||
DEK.fill(0);
|
||||
}
|
||||
|
||||
async setupOIDCUserEncryption(userId: string): Promise<void> {
|
||||
async setupOIDCUserEncryption(
|
||||
userId: string,
|
||||
sessionDurationMs: number,
|
||||
): Promise<void> {
|
||||
const existingEncryptedDEK = await this.getEncryptedDEK(userId);
|
||||
|
||||
let DEK: Buffer;
|
||||
@@ -104,14 +105,17 @@ class UserCrypto {
|
||||
const now = Date.now();
|
||||
this.userSessions.set(userId, {
|
||||
dataKey: Buffer.from(DEK),
|
||||
lastActivity: now,
|
||||
expiresAt: now + UserCrypto.SESSION_DURATION,
|
||||
expiresAt: now + sessionDurationMs,
|
||||
});
|
||||
|
||||
DEK.fill(0);
|
||||
}
|
||||
|
||||
async authenticateUser(userId: string, password: string): Promise<boolean> {
|
||||
async authenticateUser(
|
||||
userId: string,
|
||||
password: string,
|
||||
sessionDurationMs: number,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const kekSalt = await this.getKEKSalt(userId);
|
||||
if (!kekSalt) return false;
|
||||
@@ -144,8 +148,7 @@ class UserCrypto {
|
||||
|
||||
this.userSessions.set(userId, {
|
||||
dataKey: Buffer.from(DEK),
|
||||
lastActivity: now,
|
||||
expiresAt: now + UserCrypto.SESSION_DURATION,
|
||||
expiresAt: now + sessionDurationMs,
|
||||
});
|
||||
|
||||
DEK.fill(0);
|
||||
@@ -161,13 +164,49 @@ class UserCrypto {
|
||||
}
|
||||
}
|
||||
|
||||
async authenticateOIDCUser(userId: string): Promise<boolean> {
|
||||
async authenticateOIDCUser(
|
||||
userId: string,
|
||||
sessionDurationMs: number,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const oidcEncryptedDEK = await this.getOIDCEncryptedDEK(userId);
|
||||
|
||||
if (oidcEncryptedDEK) {
|
||||
const systemKey = this.deriveOIDCSystemKey(userId);
|
||||
const DEK = this.decryptDEK(oidcEncryptedDEK, systemKey);
|
||||
systemKey.fill(0);
|
||||
|
||||
if (!DEK || DEK.length === 0) {
|
||||
databaseLogger.error(
|
||||
"Failed to decrypt OIDC DEK for dual-auth user",
|
||||
{
|
||||
operation: "oidc_auth_dual_decrypt_failed",
|
||||
userId,
|
||||
},
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const oldSession = this.userSessions.get(userId);
|
||||
if (oldSession) {
|
||||
oldSession.dataKey.fill(0);
|
||||
}
|
||||
|
||||
this.userSessions.set(userId, {
|
||||
dataKey: Buffer.from(DEK),
|
||||
expiresAt: now + sessionDurationMs,
|
||||
});
|
||||
|
||||
DEK.fill(0);
|
||||
return true;
|
||||
}
|
||||
|
||||
const kekSalt = await this.getKEKSalt(userId);
|
||||
const encryptedDEK = await this.getEncryptedDEK(userId);
|
||||
|
||||
if (!kekSalt || !encryptedDEK) {
|
||||
await this.setupOIDCUserEncryption(userId);
|
||||
await this.setupOIDCUserEncryption(userId, sessionDurationMs);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -176,7 +215,7 @@ class UserCrypto {
|
||||
systemKey.fill(0);
|
||||
|
||||
if (!DEK || DEK.length === 0) {
|
||||
await this.setupOIDCUserEncryption(userId);
|
||||
await this.setupOIDCUserEncryption(userId, sessionDurationMs);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -189,15 +228,19 @@ class UserCrypto {
|
||||
|
||||
this.userSessions.set(userId, {
|
||||
dataKey: Buffer.from(DEK),
|
||||
lastActivity: now,
|
||||
expiresAt: now + UserCrypto.SESSION_DURATION,
|
||||
expiresAt: now + sessionDurationMs,
|
||||
});
|
||||
|
||||
DEK.fill(0);
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
await this.setupOIDCUserEncryption(userId);
|
||||
} catch (error) {
|
||||
databaseLogger.error("OIDC authentication failed", error, {
|
||||
operation: "oidc_auth_error",
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : "Unknown",
|
||||
});
|
||||
await this.setupOIDCUserEncryption(userId, sessionDurationMs);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -219,16 +262,6 @@ class UserCrypto {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (now - session.lastActivity > UserCrypto.MAX_INACTIVITY) {
|
||||
this.userSessions.delete(userId);
|
||||
session.dataKey.fill(0);
|
||||
if (this.sessionExpiredCallback) {
|
||||
this.sessionExpiredCallback(userId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
session.lastActivity = now;
|
||||
return session.dataKey;
|
||||
}
|
||||
|
||||
@@ -331,6 +364,83 @@ class UserCrypto {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a password-based user's encryption to DUAL-AUTH encryption.
|
||||
* This is used when linking an OIDC account to a password account for dual-auth.
|
||||
*
|
||||
* IMPORTANT: This does NOT delete the password-based KEK salt!
|
||||
* The user needs to maintain BOTH password and OIDC authentication methods.
|
||||
* We keep the password KEK salt so password login still works.
|
||||
* We also store the DEK encrypted with OIDC system key for OIDC login.
|
||||
*/
|
||||
async convertToOIDCEncryption(userId: string): Promise<void> {
|
||||
try {
|
||||
const existingEncryptedDEK = await this.getEncryptedDEK(userId);
|
||||
const existingKEKSalt = await this.getKEKSalt(userId);
|
||||
|
||||
if (!existingEncryptedDEK && !existingKEKSalt) {
|
||||
databaseLogger.warn("No existing encryption to convert for user", {
|
||||
operation: "convert_to_oidc_encryption_skip",
|
||||
userId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const existingDEK = this.getUserDataKey(userId);
|
||||
|
||||
if (!existingDEK) {
|
||||
throw new Error(
|
||||
"Cannot convert to OIDC encryption - user session not active. Please log in with password first.",
|
||||
);
|
||||
}
|
||||
|
||||
const systemKey = this.deriveOIDCSystemKey(userId);
|
||||
const oidcEncryptedDEK = this.encryptDEK(existingDEK, systemKey);
|
||||
systemKey.fill(0);
|
||||
|
||||
const key = `user_encrypted_dek_oidc_${userId}`;
|
||||
const value = JSON.stringify(oidcEncryptedDEK);
|
||||
|
||||
const { getDb } = await import("../database/db/index.js");
|
||||
const { settings } = await import("../database/db/schema.js");
|
||||
const { eq } = await import("drizzle-orm");
|
||||
|
||||
const existing = await getDb()
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(eq(settings.key, key));
|
||||
|
||||
if (existing.length > 0) {
|
||||
await getDb()
|
||||
.update(settings)
|
||||
.set({ value })
|
||||
.where(eq(settings.key, key));
|
||||
} else {
|
||||
await getDb().insert(settings).values({ key, value });
|
||||
}
|
||||
|
||||
databaseLogger.info(
|
||||
"Converted user encryption to dual-auth (password + OIDC)",
|
||||
{
|
||||
operation: "convert_to_oidc_encryption_preserved",
|
||||
userId,
|
||||
},
|
||||
);
|
||||
|
||||
const { saveMemoryDatabaseToFile } = await import(
|
||||
"../database/db/index.js"
|
||||
);
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to convert to OIDC encryption", error, {
|
||||
operation: "convert_to_oidc_encryption_error",
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async validatePassword(
|
||||
userId: string,
|
||||
password: string,
|
||||
@@ -359,10 +469,7 @@ class UserCrypto {
|
||||
const expiredUsers: string[] = [];
|
||||
|
||||
for (const [userId, session] of this.userSessions.entries()) {
|
||||
if (
|
||||
now > session.expiresAt ||
|
||||
now - session.lastActivity > UserCrypto.MAX_INACTIVITY
|
||||
) {
|
||||
if (now > session.expiresAt) {
|
||||
session.dataKey.fill(0);
|
||||
expiredUsers.push(userId);
|
||||
}
|
||||
@@ -512,6 +619,26 @@ class UserCrypto {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async getOIDCEncryptedDEK(
|
||||
userId: string,
|
||||
): Promise<EncryptedDEK | null> {
|
||||
try {
|
||||
const key = `user_encrypted_dek_oidc_${userId}`;
|
||||
const result = await getDb()
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(eq(settings.key, key));
|
||||
|
||||
if (result.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(result[0].value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { UserCrypto, type KEKSalt, type EncryptedDEK };
|
||||
|
||||
@@ -6,7 +6,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-100 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-95",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
184
src/components/ui/command.tsx
Normal file
184
src/components/ui/command.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { SearchIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn("overflow-hidden p-0", className)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
};
|
||||
141
src/components/ui/dialog.tsx
Normal file
141
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] duration-200 outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
|
||||
28
src/components/ui/kbd.tsx
Normal file
28
src/components/ui/kbd.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none",
|
||||
"[&_svg:not([class*='size-'])]:size-3",
|
||||
"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd-group"
|
||||
className={cn("inline-flex items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Kbd, KbdGroup };
|
||||
@@ -40,7 +40,7 @@ function TabsTrigger({
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] duration-200 focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -55,7 +55,13 @@ function TabsContent({
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
className={cn(
|
||||
"flex-1 outline-none",
|
||||
"data-[state=active]:animate-in data-[state=inactive]:animate-out",
|
||||
"data-[state=inactive]:fade-out-0 data-[state=active]:fade-in-0",
|
||||
"duration-150",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -617,7 +617,6 @@ export const TERMINAL_THEMES: Record<string, TerminalTheme> = {
|
||||
},
|
||||
};
|
||||
|
||||
// Font families available for terminal
|
||||
export const TERMINAL_FONTS = [
|
||||
{
|
||||
value: "Caskaydia Cove Nerd Font Mono",
|
||||
@@ -706,6 +705,7 @@ export const DEFAULT_TERMINAL_CONFIG = {
|
||||
startupSnippetId: null as number | null,
|
||||
autoMosh: false,
|
||||
moshCommand: "mosh-server new -s -l LANG=en_US.UTF-8",
|
||||
sudoPasswordAutoFill: false,
|
||||
};
|
||||
|
||||
export type TerminalConfigType = typeof DEFAULT_TERMINAL_CONFIG;
|
||||
|
||||
@@ -7,12 +7,14 @@ import zhTranslation from "../locales/zh/translation.json";
|
||||
import deTranslation from "../locales/de/translation.json";
|
||||
import ptbrTranslation from "../locales/pt-BR/translation.json";
|
||||
import ruTranslation from "../locales/ru/translation.json";
|
||||
import frTranslation from "../locales/fr/translation.json";
|
||||
import koTranslation from "../locales/ko/translation.json";
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
supportedLngs: ["en", "zh", "de", "ptbr", "ru"],
|
||||
supportedLngs: ["en", "zh", "de", "ptbr", "ru", "fr", "ko"],
|
||||
fallbackLng: "en",
|
||||
debug: false,
|
||||
|
||||
@@ -40,6 +42,12 @@ i18n
|
||||
ru: {
|
||||
translation: ruTranslation,
|
||||
},
|
||||
fr: {
|
||||
translation: frTranslation,
|
||||
},
|
||||
ko: {
|
||||
translation: koTranslation,
|
||||
},
|
||||
},
|
||||
|
||||
interpolation: {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -59,7 +59,6 @@
|
||||
"keyTypeRSA": "RSA",
|
||||
"keyTypeECDSA": "ECDSA",
|
||||
"keyTypeEd25519": "Ed25519",
|
||||
"updateCredential": "Update Credential",
|
||||
"basicInfo": "Basic Info",
|
||||
"authentication": "Authentication",
|
||||
"organization": "Organization",
|
||||
@@ -109,6 +108,7 @@
|
||||
"orCreateNewFolder": "Or create new folder",
|
||||
"addTag": "Add tag",
|
||||
"saving": "Saving...",
|
||||
"credentialId": "Credential ID",
|
||||
"overview": "Overview",
|
||||
"security": "Security",
|
||||
"usage": "Usage",
|
||||
@@ -118,7 +118,6 @@
|
||||
"credentialSecuredDescription": "All sensitive data is encrypted with AES-256",
|
||||
"passwordAuthentication": "Password Authentication",
|
||||
"keyAuthentication": "Key Authentication",
|
||||
"keyType": "Key Type",
|
||||
"securityReminder": "Security Reminder",
|
||||
"securityReminderText": "Never share your credentials. All data is encrypted at rest.",
|
||||
"hostsUsingCredential": "Hosts Using This Credential",
|
||||
@@ -225,6 +224,20 @@
|
||||
"editTooltip": "Edit this snippet",
|
||||
"deleteTooltip": "Delete this snippet"
|
||||
},
|
||||
"commandHistory": {
|
||||
"title": "History",
|
||||
"searchPlaceholder": "Search commands...",
|
||||
"noTerminal": "No active terminal",
|
||||
"noTerminalHint": "Open a terminal to see its command history.",
|
||||
"empty": "No command history yet",
|
||||
"emptyHint": "Execute commands in the active terminal to build its history.",
|
||||
"noResults": "No commands found",
|
||||
"noResultsHint": "No commands matching \"{{query}}\"",
|
||||
"deleteSuccess": "Command deleted from history",
|
||||
"deleteFailed": "Failed to delete command.",
|
||||
"deleteTooltip": "Delete command",
|
||||
"tabHint": "Use Tab in Terminal to autocomplete from command history"
|
||||
},
|
||||
"homepage": {
|
||||
"loggedInTitle": "Logged in!",
|
||||
"loggedInMessage": "You are logged in! Use the sidebar to access all available tools. To get started, create an SSH Host in the SSH Manager tab. Once created, you can connect to that host using the other apps in the sidebar.",
|
||||
@@ -284,7 +297,7 @@
|
||||
"warning": "Warning",
|
||||
"info": "Info",
|
||||
"success": "Success",
|
||||
"loading": "Loading",
|
||||
"loading": "Loading...",
|
||||
"required": "Required",
|
||||
"optional": "Optional",
|
||||
"connect": "Connect",
|
||||
@@ -300,7 +313,6 @@
|
||||
"updateAvailable": "Update Available",
|
||||
"sshPath": "SSH Path",
|
||||
"localPath": "Local Path",
|
||||
"loading": "Loading...",
|
||||
"noAuthCredentials": "No authentication credentials available for this SSH host",
|
||||
"noReleases": "No Releases",
|
||||
"updatesAndReleases": "Updates & Releases",
|
||||
@@ -315,13 +327,10 @@
|
||||
"resetPassword": "Reset Password",
|
||||
"resetCode": "Reset Code",
|
||||
"newPassword": "New Password",
|
||||
"sshPath": "SSH Path",
|
||||
"localPath": "Local Path",
|
||||
"folder": "Folder",
|
||||
"file": "File",
|
||||
"renamedSuccessfully": "renamed successfully",
|
||||
"deletedSuccessfully": "deleted successfully",
|
||||
"noAuthCredentials": "No authentication credentials available for this SSH host",
|
||||
"noTunnelConnections": "No tunnel connections configured",
|
||||
"sshTools": "SSH Tools",
|
||||
"english": "English",
|
||||
@@ -333,36 +342,28 @@
|
||||
"login": "Login",
|
||||
"logout": "Logout",
|
||||
"register": "Register",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"version": "Version",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"back": "Back",
|
||||
"email": "Email",
|
||||
"submit": "Submit",
|
||||
"cancel": "Cancel",
|
||||
"change": "Change",
|
||||
"save": "Save",
|
||||
"saving": "Saving...",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"add": "Add",
|
||||
"search": "Search",
|
||||
"loading": "Loading...",
|
||||
"error": "Error",
|
||||
"success": "Success",
|
||||
"warning": "Warning",
|
||||
"info": "Info",
|
||||
"confirm": "Confirm",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"ok": "OK",
|
||||
"close": "Close",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"important": "Important",
|
||||
"notEnabled": "Not Enabled",
|
||||
"settingUp": "Setting up...",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"previous": "Previous",
|
||||
"refresh": "Refresh",
|
||||
@@ -415,7 +416,7 @@
|
||||
"userManagement": "User Management",
|
||||
"makeAdmin": "Make Admin",
|
||||
"removeAdmin": "Remove Admin",
|
||||
"deleteUser": "Delete User",
|
||||
"deleteUser": "Delete user {{username}}? This cannot be undone.",
|
||||
"allowRegistration": "Allow Registration",
|
||||
"oidcSettings": "OIDC Settings",
|
||||
"clientId": "Client ID",
|
||||
@@ -469,7 +470,6 @@
|
||||
"removeAdminStatus": "Remove admin status from {{username}}?",
|
||||
"adminStatusRemoved": "Admin status removed from {{username}}",
|
||||
"failedToRemoveAdminStatus": "Failed to remove admin status",
|
||||
"deleteUser": "Delete user {{username}}? This cannot be undone.",
|
||||
"userDeletedSuccessfully": "User {{username}} deleted successfully",
|
||||
"failedToDeleteUser": "Failed to delete user",
|
||||
"overrideUserInfoUrl": "Override User Info URL (not required)",
|
||||
@@ -480,6 +480,24 @@
|
||||
"confirmRevokeAllSessions": "Are you sure you want to revoke all sessions for this user?",
|
||||
"failedToRevokeSessions": "Failed to revoke sessions",
|
||||
"sessionsRevokedSuccessfully": "Sessions revoked successfully",
|
||||
"linkToPasswordAccount": "Link to Password Account",
|
||||
"linkOIDCDialogTitle": "Link OIDC Account to Password Account",
|
||||
"linkOIDCDialogDescription": "Link {{username}} (OIDC user) to an existing password account. This will enable dual authentication for the password account.",
|
||||
"linkOIDCWarningTitle": "Warning: OIDC User Data Will Be Deleted",
|
||||
"linkOIDCActionDeleteUser": "Delete the OIDC user account and all their data",
|
||||
"linkOIDCActionAddCapability": "Add OIDC login capability to the target password account",
|
||||
"linkOIDCActionDualAuth": "Allow the password account to login with both password and OIDC",
|
||||
"linkTargetUsernameLabel": "Target Password Account Username",
|
||||
"linkTargetUsernamePlaceholder": "Enter username of password account",
|
||||
"linkAccountsButton": "Link Accounts",
|
||||
"linkingAccounts": "Linking...",
|
||||
"accountsLinkedSuccessfully": "OIDC user {{oidcUsername}} has been linked to {{targetUsername}}",
|
||||
"failedToLinkAccounts": "Failed to link accounts",
|
||||
"linkTargetUsernameRequired": "Target username is required",
|
||||
"unlinkOIDCTitle": "Unlink OIDC Authentication",
|
||||
"unlinkOIDCDescription": "Remove OIDC authentication from {{username}}? The user will only be able to login with username/password after this.",
|
||||
"unlinkOIDCSuccess": "OIDC unlinked from {{username}}",
|
||||
"failedToUnlinkOIDC": "Failed to unlink OIDC",
|
||||
"databaseSecurity": "Database Security",
|
||||
"encryptionStatus": "Encryption Status",
|
||||
"encryptionEnabled": "Encryption Enabled",
|
||||
@@ -529,7 +547,6 @@
|
||||
"verificationCompleted": "Compatibility verification completed - no data was changed",
|
||||
"verificationInProgress": "Verification completed",
|
||||
"dataMigrationCompleted": "Data migration completed successfully!",
|
||||
"migrationCompleted": "Migration completed",
|
||||
"verificationFailed": "Compatibility verification failed",
|
||||
"migrationFailed": "Migration failed",
|
||||
"runningVerification": "Running compatibility verification...",
|
||||
@@ -607,7 +624,8 @@
|
||||
"requiresPasswordLogin": "Requires password login enabled",
|
||||
"passwordLoginDisabledWarning": "Password login is disabled. Ensure OIDC is properly configured or you will not be able to log in to Termix.",
|
||||
"oidcRequiredWarning": "CRITICAL: Password login is disabled. If you reset or misconfigure OIDC, you will lose all access to Termix and brick your instance. Only proceed if you are absolutely certain.",
|
||||
"confirmDisableOIDCWarning": "WARNING: You are about to disable OIDC while password login is also disabled. This will brick your Termix instance and you will lose all access. Are you absolutely sure you want to proceed?"
|
||||
"confirmDisableOIDCWarning": "WARNING: You are about to disable OIDC while password login is also disabled. This will brick your Termix instance and you will lose all access. Are you absolutely sure you want to proceed?",
|
||||
"failedToUpdatePasswordLoginStatus": "Failed to update password login status"
|
||||
},
|
||||
"hosts": {
|
||||
"title": "Host Manager",
|
||||
@@ -754,6 +772,17 @@
|
||||
"failedToRemoveFromFolder": "Failed to remove host from folder",
|
||||
"folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully",
|
||||
"failedToRenameFolder": "Failed to rename folder",
|
||||
"editFolderAppearance": "Edit Folder Appearance",
|
||||
"editFolderAppearanceDesc": "Customize the color and icon for folder",
|
||||
"folderColor": "Folder Color",
|
||||
"folderIcon": "Folder Icon",
|
||||
"preview": "Preview",
|
||||
"folderAppearanceUpdated": "Folder appearance updated successfully",
|
||||
"failedToUpdateFolderAppearance": "Failed to update folder appearance",
|
||||
"deleteAllHostsInFolder": "Delete All Hosts in Folder",
|
||||
"confirmDeleteAllHostsInFolder": "Are you sure you want to delete all {{count}} hosts in folder \"{{folder}}\"? This action cannot be undone.",
|
||||
"allHostsInFolderDeleted": "Deleted {{count}} hosts from folder \"{{folder}}\" successfully",
|
||||
"failedToDeleteHostsInFolder": "Failed to delete hosts in folder",
|
||||
"movedToFolder": "Host \"{{name}}\" moved to \"{{folder}}\" successfully",
|
||||
"failedToMoveToFolder": "Failed to move host to folder",
|
||||
"statistics": "Statistics",
|
||||
@@ -778,11 +807,94 @@
|
||||
"statusMonitoring": "Status",
|
||||
"metricsMonitoring": "Metrics",
|
||||
"terminalCustomizationNotice": "Note: Terminal customizations only work on desktop (website and Electron app). Mobile apps and mobile website use system default terminal settings.",
|
||||
"terminalCustomization": "Terminal Customization",
|
||||
"appearance": "Appearance",
|
||||
"behavior": "Behavior",
|
||||
"advanced": "Advanced",
|
||||
"themePreview": "Theme Preview",
|
||||
"theme": "Theme",
|
||||
"selectTheme": "Select theme",
|
||||
"chooseColorTheme": "Choose a color theme for the terminal",
|
||||
"fontFamily": "Font Family",
|
||||
"selectFont": "Select font",
|
||||
"selectFontDesc": "Select the font to use in the terminal",
|
||||
"fontSize": "Font Size",
|
||||
"fontSizeValue": "Font Size: {{value}}px",
|
||||
"adjustFontSize": "Adjust the terminal font size",
|
||||
"letterSpacing": "Letter Spacing",
|
||||
"letterSpacingValue": "Letter Spacing: {{value}}px",
|
||||
"adjustLetterSpacing": "Adjust spacing between characters",
|
||||
"lineHeight": "Line Height",
|
||||
"lineHeightValue": "Line Height: {{value}}",
|
||||
"adjustLineHeight": "Adjust spacing between lines",
|
||||
"cursorStyle": "Cursor Style",
|
||||
"selectCursorStyle": "Select cursor style",
|
||||
"cursorStyleBlock": "Block",
|
||||
"cursorStyleUnderline": "Underline",
|
||||
"cursorStyleBar": "Bar",
|
||||
"chooseCursorAppearance": "Choose the cursor appearance",
|
||||
"cursorBlink": "Cursor Blink",
|
||||
"enableCursorBlink": "Enable cursor blinking animation",
|
||||
"scrollbackBuffer": "Scrollback Buffer",
|
||||
"scrollbackBufferValue": "Scrollback Buffer: {{value}} lines",
|
||||
"scrollbackBufferDesc": "Number of lines to keep in scrollback history",
|
||||
"bellStyle": "Bell Style",
|
||||
"selectBellStyle": "Select bell style",
|
||||
"bellStyleNone": "None",
|
||||
"bellStyleSound": "Sound",
|
||||
"bellStyleVisual": "Visual",
|
||||
"bellStyleBoth": "Both",
|
||||
"bellStyleDesc": "How to handle terminal bell (BEL character, \\x07). Programs trigger this when completing tasks, encountering errors, or for notifications. \"Sound\" plays an audio beep, \"Visual\" flashes the screen briefly, \"Both\" does both, \"None\" disables bell alerts.",
|
||||
"rightClickSelectsWord": "Right Click Selects Word",
|
||||
"rightClickSelectsWordDesc": "Right-clicking selects the word under cursor",
|
||||
"fastScrollModifier": "Fast Scroll Modifier",
|
||||
"selectModifier": "Select modifier",
|
||||
"modifierAlt": "Alt",
|
||||
"modifierCtrl": "Ctrl",
|
||||
"modifierShift": "Shift",
|
||||
"fastScrollModifierDesc": "Modifier key for fast scrolling",
|
||||
"fastScrollSensitivity": "Fast Scroll Sensitivity",
|
||||
"fastScrollSensitivityValue": "Fast Scroll Sensitivity: {{value}}",
|
||||
"fastScrollSensitivityDesc": "Scroll speed multiplier when modifier is held",
|
||||
"minimumContrastRatio": "Minimum Contrast Ratio",
|
||||
"minimumContrastRatioValue": "Minimum Contrast Ratio: {{value}}",
|
||||
"minimumContrastRatioDesc": "Automatically adjust colors for better readability",
|
||||
"sshAgentForwarding": "SSH Agent Forwarding",
|
||||
"sshAgentForwardingDesc": "Forward SSH authentication agent to remote host",
|
||||
"backspaceMode": "Backspace Mode",
|
||||
"selectBackspaceMode": "Select backspace mode",
|
||||
"backspaceModeNormal": "Normal (DEL)",
|
||||
"backspaceModeControlH": "Control-H (^H)",
|
||||
"backspaceModeDesc": "Backspace key behavior for compatibility",
|
||||
"startupSnippet": "Startup Snippet",
|
||||
"selectSnippet": "Select snippet",
|
||||
"searchSnippets": "Search snippets...",
|
||||
"snippetNone": "None",
|
||||
"noneAuthTitle": "Keyboard-Interactive Authentication",
|
||||
"noneAuthDescription": "This authentication method will use keyboard-interactive authentication when connecting to the SSH server.",
|
||||
"noneAuthDetails": "Keyboard-interactive authentication allows the server to prompt you for credentials during connection. This is useful for servers that require multi-factor authentication or if you do not want to save credentials locally.",
|
||||
"forceKeyboardInteractive": "Force Keyboard-Interactive",
|
||||
"forceKeyboardInteractiveDesc": "Forces the use of keyboard-interactive authentication. This is often required for servers that use Two-Factor Authentication (TOTP/2FA)."
|
||||
"forceKeyboardInteractiveDesc": "Forces the use of keyboard-interactive authentication. This is often required for servers that use Two-Factor Authentication (TOTP/2FA).",
|
||||
"overrideCredentialUsername": "Override Credential Username",
|
||||
"overrideCredentialUsernameDesc": "Use a different username than the one stored in the credential. This allows you to use the same credential with different usernames.",
|
||||
"jumpHosts": "Jump Hosts",
|
||||
"jumpHostsDescription": "Jump hosts (also known as bastion hosts) allow you to connect to a target server through one or more intermediate servers. This is useful for accessing servers behind firewalls or in private networks.",
|
||||
"jumpHostChain": "Jump Host Chain",
|
||||
"addJumpHost": "Add Jump Host",
|
||||
"selectServer": "Select Server",
|
||||
"searchServers": "Search servers...",
|
||||
"noServerFound": "No server found",
|
||||
"jumpHostsOrder": "Connections will be made in order: Jump Host 1 → Jump Host 2 → ... → Target Server",
|
||||
"quickActions": "Quick Actions",
|
||||
"quickActionsDescription": "Quick actions allow you to create custom buttons that execute SSH snippets on this server. These buttons will appear at the top of the Server Stats page for quick access.",
|
||||
"quickActionsList": "Quick Actions List",
|
||||
"addQuickAction": "Add Quick Action",
|
||||
"quickActionName": "Action name",
|
||||
"noSnippetFound": "No snippet found",
|
||||
"quickActionsOrder": "Quick action buttons will appear in the order listed above on the Server Stats page",
|
||||
"advancedAuthSettings": "Advanced Authentication Settings",
|
||||
"sudoPasswordAutoFill": "Sudo Password Auto-Fill",
|
||||
"sudoPasswordAutoFillDesc": "Automatically offer to insert SSH password when sudo prompts for password"
|
||||
},
|
||||
"terminal": {
|
||||
"title": "Terminal",
|
||||
@@ -820,7 +932,11 @@
|
||||
"totpRequired": "Two-Factor Authentication Required",
|
||||
"totpCodeLabel": "Verification Code",
|
||||
"totpPlaceholder": "000000",
|
||||
"totpVerify": "Verify"
|
||||
"totpVerify": "Verify",
|
||||
"sudoPasswordPopupTitle": "Insert Password?",
|
||||
"sudoPasswordPopupHint": "Press Enter to insert, Esc to dismiss",
|
||||
"sudoPasswordPopupConfirm": "Insert",
|
||||
"sudoPasswordPopupDismiss": "Dismiss"
|
||||
},
|
||||
"fileManager": {
|
||||
"title": "File Manager",
|
||||
@@ -829,6 +945,22 @@
|
||||
"connectToSsh": "Connect to SSH to use file operations",
|
||||
"uploadFile": "Upload File",
|
||||
"downloadFile": "Download",
|
||||
"extractArchive": "Extract Archive",
|
||||
"extractingArchive": "Extracting {{name}}...",
|
||||
"archiveExtractedSuccessfully": "{{name}} extracted successfully",
|
||||
"extractFailed": "Extract failed",
|
||||
"compressFile": "Compress File",
|
||||
"compressFiles": "Compress Files",
|
||||
"compressFilesDesc": "Compress {{count}} items into an archive",
|
||||
"archiveName": "Archive Name",
|
||||
"enterArchiveName": "Enter archive name...",
|
||||
"compressionFormat": "Compression Format",
|
||||
"selectedFiles": "Selected files",
|
||||
"andMoreFiles": "and {{count}} more...",
|
||||
"compress": "Compress",
|
||||
"compressingFiles": "Compressing {{count}} items into {{name}}...",
|
||||
"filesCompressedSuccessfully": "{{name}} created successfully",
|
||||
"compressFailed": "Compression failed",
|
||||
"edit": "Edit",
|
||||
"preview": "Preview",
|
||||
"previous": "Previous",
|
||||
@@ -902,9 +1034,10 @@
|
||||
"copy": "Copy",
|
||||
"cut": "Cut",
|
||||
"paste": "Paste",
|
||||
"copyPath": "Copy Path",
|
||||
"copyPaths": "Copy Paths",
|
||||
"delete": "Delete",
|
||||
"properties": "Properties",
|
||||
"preview": "Preview",
|
||||
"refresh": "Refresh",
|
||||
"downloadFiles": "Download {{count}} files to Browser",
|
||||
"copyFiles": "Copy {{count}} items",
|
||||
@@ -912,22 +1045,18 @@
|
||||
"deleteFiles": "Delete {{count}} items",
|
||||
"filesCopiedToClipboard": "{{count}} items copied to clipboard",
|
||||
"filesCutToClipboard": "{{count}} items cut to clipboard",
|
||||
"pathCopiedToClipboard": "Path copied to clipboard",
|
||||
"pathsCopiedToClipboard": "{{count}} paths copied to clipboard",
|
||||
"failedToCopyPath": "Failed to copy path to clipboard",
|
||||
"movedItems": "Moved {{count}} items",
|
||||
"failedToDeleteItem": "Failed to delete item",
|
||||
"itemRenamedSuccessfully": "{{type}} renamed successfully",
|
||||
"failedToRenameItem": "Failed to rename item",
|
||||
"upload": "Upload",
|
||||
"download": "Download",
|
||||
"newFile": "New File",
|
||||
"newFolder": "New Folder",
|
||||
"rename": "Rename",
|
||||
"delete": "Delete",
|
||||
"permissions": "Permissions",
|
||||
"size": "Size",
|
||||
"modified": "Modified",
|
||||
"path": "Path",
|
||||
"fileName": "File Name",
|
||||
"folderName": "Folder Name",
|
||||
"confirmDelete": "Are you sure you want to delete {{name}}?",
|
||||
"uploadSuccess": "File uploaded successfully",
|
||||
"uploadFailed": "File upload failed",
|
||||
@@ -947,10 +1076,7 @@
|
||||
"fileSavedSuccessfully": "File saved successfully",
|
||||
"saveTimeout": "Save operation timed out. The file may have been saved successfully, but the operation took too long to complete. Check the Docker logs for confirmation.",
|
||||
"failedToSaveFile": "Failed to save file",
|
||||
"folder": "Folder",
|
||||
"file": "File",
|
||||
"deletedSuccessfully": "deleted successfully",
|
||||
"failedToDeleteItem": "Failed to delete item",
|
||||
"connectToServer": "Connect to a Server",
|
||||
"selectServerToEdit": "Select a server from the sidebar to start editing files",
|
||||
"fileOperations": "File Operations",
|
||||
@@ -1007,10 +1133,8 @@
|
||||
"unpinFile": "Unpin file",
|
||||
"removeShortcut": "Remove shortcut",
|
||||
"saveFilesToSystem": "Save {{count}} files as...",
|
||||
"saveToSystem": "Save as...",
|
||||
"pinFile": "Pin file",
|
||||
"addToShortcuts": "Add to shortcuts",
|
||||
"selectLocationToSave": "Select location to save",
|
||||
"downloadToDefaultLocation": "Download to default location",
|
||||
"pasteFailed": "Paste failed",
|
||||
"noUndoableActions": "No undoable actions",
|
||||
@@ -1028,7 +1152,6 @@
|
||||
"editPath": "Edit path",
|
||||
"confirm": "Confirm",
|
||||
"cancel": "Cancel",
|
||||
"folderName": "Folder name",
|
||||
"find": "Find...",
|
||||
"replaceWith": "Replace with...",
|
||||
"replace": "Replace",
|
||||
@@ -1054,23 +1177,18 @@
|
||||
"outdent": "Outdent",
|
||||
"autoComplete": "Auto Complete",
|
||||
"imageLoadError": "Failed to load image",
|
||||
"zoomIn": "Zoom In",
|
||||
"zoomOut": "Zoom Out",
|
||||
"rotate": "Rotate",
|
||||
"originalSize": "Original Size",
|
||||
"startTyping": "Start typing...",
|
||||
"unknownSize": "Unknown size",
|
||||
"fileIsEmpty": "File is empty",
|
||||
"modified": "Modified",
|
||||
"largeFileWarning": "Large File Warning",
|
||||
"largeFileWarningDesc": "This file is {{size}} in size, which may cause performance issues when opened as text.",
|
||||
"fileNotFoundAndRemoved": "File \"{{name}}\" not found and has been removed from recent/pinned files",
|
||||
"failedToLoadFile": "Failed to load file: {{error}}",
|
||||
"serverErrorOccurred": "Server error occurred. Please try again later.",
|
||||
"fileSavedSuccessfully": "File saved successfully",
|
||||
"autoSaveFailed": "Auto-save failed",
|
||||
"fileAutoSaved": "File auto-saved",
|
||||
"fileDownloadedSuccessfully": "File downloaded successfully",
|
||||
"moveFileFailed": "Failed to move {{name}}",
|
||||
"moveOperationFailed": "Move operation failed",
|
||||
"canOnlyCompareFiles": "Can only compare two files",
|
||||
@@ -1106,7 +1224,19 @@
|
||||
"sshConnectionFailed": "SSH connection failed. Please check your connection to {{name}} ({{ip}}:{{port}})",
|
||||
"loadFileFailed": "Failed to load file: {{error}}",
|
||||
"connectedSuccessfully": "Connected successfully",
|
||||
"totpVerificationFailed": "TOTP verification failed"
|
||||
"totpVerificationFailed": "TOTP verification failed",
|
||||
"changePermissions": "Change Permissions",
|
||||
"changePermissionsDesc": "Modify file permissions for",
|
||||
"currentPermissions": "Current Permissions",
|
||||
"newPermissions": "New Permissions",
|
||||
"owner": "Owner",
|
||||
"group": "Group",
|
||||
"others": "Others",
|
||||
"read": "Read",
|
||||
"write": "Write",
|
||||
"execute": "Execute",
|
||||
"permissionsChangedSuccessfully": "Permissions changed successfully",
|
||||
"failedToChangePermissions": "Failed to change permissions"
|
||||
},
|
||||
"tunnels": {
|
||||
"title": "SSH Tunnels",
|
||||
@@ -1152,17 +1282,8 @@
|
||||
"local": "Local",
|
||||
"remote": "Remote",
|
||||
"dynamic": "Dynamic",
|
||||
"noSshTunnels": "No SSH Tunnels",
|
||||
"createFirstTunnelMessage": "Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel connections.",
|
||||
"unknownConnectionStatus": "Unknown",
|
||||
"connected": "Connected",
|
||||
"connecting": "Connecting...",
|
||||
"disconnecting": "Disconnecting...",
|
||||
"disconnected": "Disconnected",
|
||||
"portMapping": "Port {{sourcePort}} → {{endpointHost}}:{{endpointPort}}",
|
||||
"disconnect": "Disconnect",
|
||||
"connect": "Connect",
|
||||
"canceling": "Canceling...",
|
||||
"endpointHostNotFound": "Endpoint host not found",
|
||||
"discord": "Discord",
|
||||
"githubIssue": "GitHub issue",
|
||||
@@ -1175,7 +1296,7 @@
|
||||
"disk": "Disk",
|
||||
"network": "Network",
|
||||
"uptime": "Uptime",
|
||||
"loadAverage": "Load Average",
|
||||
"loadAverage": "Avg: {{avg1}}, {{avg5}}, {{avg15}}",
|
||||
"processes": "Processes",
|
||||
"connections": "Connections",
|
||||
"usage": "Usage",
|
||||
@@ -1191,7 +1312,6 @@
|
||||
"cpuCores_one": "{{count}} CPU",
|
||||
"cpuCores_other": "{{count}} CPUs",
|
||||
"naCpus": "N/A CPU(s)",
|
||||
"loadAverage": "Avg: {{avg1}}, {{avg5}}, {{avg15}}",
|
||||
"loadAverageNA": "Avg: N/A",
|
||||
"cpuUsage": "CPU Usage",
|
||||
"memoryUsage": "Memory Usage",
|
||||
@@ -1210,8 +1330,6 @@
|
||||
"totpRequired": "TOTP Authentication Required",
|
||||
"totpUnavailable": "Server Stats unavailable for TOTP-enabled servers",
|
||||
"load": "Load",
|
||||
"free": "Free",
|
||||
"available": "Available",
|
||||
"editLayout": "Edit Layout",
|
||||
"cancelEdit": "Cancel",
|
||||
"addWidget": "Add Widget",
|
||||
@@ -1229,9 +1347,27 @@
|
||||
"noInterfacesFound": "No network interfaces found",
|
||||
"totalProcesses": "Total Processes",
|
||||
"running": "Running",
|
||||
"noProcessesFound": "No processes found"
|
||||
"noProcessesFound": "No processes found",
|
||||
"loginStats": "SSH Login Statistics",
|
||||
"totalLogins": "Total Logins",
|
||||
"uniqueIPs": "Unique IPs",
|
||||
"recentSuccessfulLogins": "Recent Successful Logins",
|
||||
"recentFailedAttempts": "Recent Failed Attempts",
|
||||
"noRecentLoginData": "No recent login data",
|
||||
"from": "from",
|
||||
"quickActions": "Quick Actions",
|
||||
"executeQuickAction": "Execute {{name}}",
|
||||
"executingQuickAction": "Executing {{name}}...",
|
||||
"quickActionSuccess": "{{name}} completed successfully",
|
||||
"quickActionFailed": "{{name}} failed",
|
||||
"quickActionError": "Failed to execute {{name}}"
|
||||
},
|
||||
"auth": {
|
||||
"tagline": "SSH SERVER MANAGER",
|
||||
"description": "Secure, powerful, and intuitive SSH connection management",
|
||||
"welcomeBack": "Welcome back to TERMIX",
|
||||
"createAccount": "Create your TERMIX account",
|
||||
"continueExternal": "Continue with external provider",
|
||||
"loginTitle": "Login to Termix",
|
||||
"registerTitle": "Create Account",
|
||||
"loginButton": "Login",
|
||||
@@ -1332,7 +1468,9 @@
|
||||
"authenticating": "Authenticating...",
|
||||
"dataLossWarning": "Resetting your password this way will delete all your saved SSH hosts, credentials, and other encrypted data. This action cannot be undone. Only use this if you have forgotten your password and are not logged in.",
|
||||
"authenticationDisabled": "Authentication Disabled",
|
||||
"authenticationDisabledDesc": "All authentication methods are currently disabled. Please contact your administrator."
|
||||
"authenticationDisabledDesc": "All authentication methods are currently disabled. Please contact your administrator.",
|
||||
"passwordResetSuccess": "Password Reset Successful",
|
||||
"passwordResetSuccessDesc": "Your password has been reset successfully. You can now log in with your new password."
|
||||
},
|
||||
"errors": {
|
||||
"notFound": "Page not found",
|
||||
@@ -1404,7 +1542,14 @@
|
||||
"authMethod": "Authentication Method",
|
||||
"local": "Local",
|
||||
"external": "External (OIDC)",
|
||||
"externalAndLocal": "Dual Auth",
|
||||
"selectPreferredLanguage": "Select your preferred language for the interface",
|
||||
"fileColorCoding": "File Color Coding",
|
||||
"fileColorCodingDesc": "Color-code files by type: folders (red), files (blue), symlinks (green)",
|
||||
"commandAutocomplete": "Command Autocomplete",
|
||||
"commandAutocompleteDesc": "Enable Tab key autocomplete suggestions for terminal commands based on your command history",
|
||||
"defaultSnippetFoldersCollapsed": "Collapse Snippet Folders by Default",
|
||||
"defaultSnippetFoldersCollapsedDesc": "When enabled, all snippet folders will be collapsed when you open the snippets tab",
|
||||
"currentPassword": "Current Password",
|
||||
"passwordChangedSuccess": "Password changed successfully! Please log in again.",
|
||||
"failedToChangePassword": "Failed to change password. Please check your current password and try again."
|
||||
@@ -1491,7 +1636,6 @@
|
||||
"deleteItem": "Delete Item",
|
||||
"createNewFile": "Create New File",
|
||||
"createNewFolder": "Create New Folder",
|
||||
"deleteItem": "Delete Item",
|
||||
"renameItem": "Rename Item",
|
||||
"clickToSelectFile": "Click to select a file",
|
||||
"noSshHosts": "No SSH Hosts",
|
||||
@@ -1620,5 +1764,28 @@
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM",
|
||||
"notAvailable": "N/A"
|
||||
},
|
||||
"commandPalette": {
|
||||
"searchPlaceholder": "Search for hosts or quick actions...",
|
||||
"recentActivity": "Recent Activity",
|
||||
"navigation": "Navigation",
|
||||
"addHost": "Add Host",
|
||||
"addCredential": "Add Credential",
|
||||
"adminSettings": "Admin Settings",
|
||||
"userProfile": "User Profile",
|
||||
"updateLog": "Update Log",
|
||||
"hosts": "Hosts",
|
||||
"openServerDetails": "Open Server Details",
|
||||
"openFileManager": "Open File Manager",
|
||||
"edit": "Edit",
|
||||
"links": "Links",
|
||||
"github": "GitHub",
|
||||
"support": "Support",
|
||||
"discord": "Discord",
|
||||
"donate": "Donate",
|
||||
"press": "Press",
|
||||
"toToggle": "to toggle",
|
||||
"close": "Close",
|
||||
"hostManager": "Host Manager"
|
||||
}
|
||||
}
|
||||
}
|
||||
1789
src/locales/fr/translation.json
Normal file
1789
src/locales/fr/translation.json
Normal file
File diff suppressed because it is too large
Load Diff
3470
src/locales/it/translation.json
Normal file
3470
src/locales/it/translation.json
Normal file
File diff suppressed because it is too large
Load Diff
1815
src/locales/ko/translation.json
Normal file
1815
src/locales/ko/translation.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -59,7 +59,6 @@
|
||||
"keyTypeRSA": "RSA",
|
||||
"keyTypeECDSA": "ECDSA",
|
||||
"keyTypeEd25519": "Ed25519",
|
||||
"updateCredential": "Atualizar Credencial",
|
||||
"basicInfo": "Informações básicas",
|
||||
"authentication": "Autenticação",
|
||||
"organization": "Organização",
|
||||
@@ -93,7 +92,7 @@
|
||||
"deploySSHKey": "Implantar Chave SSH",
|
||||
"deploySSHKeyDescription": "Implantar chave pública no servidor de destino",
|
||||
"sourceCredential": "Credencial de Origem",
|
||||
"targetHost": "Host de Destino",
|
||||
"targetHost": "Host de Destinenhum",
|
||||
"deploymentProcess": "Processo de Implantação",
|
||||
"deploymentProcessDescription": "Isso adicionará com segurança a chave pública ao arquivo ~/.ssh/authorized_keys do host de destino sem sobrescrever chaves existentes. A operação é reversível.",
|
||||
"chooseHostToDeploy": "Escolha um host para implantar...",
|
||||
@@ -118,7 +117,6 @@
|
||||
"credentialSecuredDescription": "Todos os dados sensíveis são criptografados com AES-256",
|
||||
"passwordAuthentication": "Autenticação por senha",
|
||||
"keyAuthentication": "Autenticação por chave",
|
||||
"keyType": "Tipo de chave",
|
||||
"securityReminder": "Lembrete de segurança",
|
||||
"securityReminderText": "Nunca compartilhe suas credenciais. Todos os dados são criptografados em repouso.",
|
||||
"hostsUsingCredential": "Hosts usando esta credencial",
|
||||
@@ -166,7 +164,8 @@
|
||||
"generateKeyPairNote": "Gere um novo par de chaves SSH diretamente. Isso substituirá quaisquer chaves existentes no formulário.",
|
||||
"invalidKey": "Chave inválida",
|
||||
"detectionError": "Erro de detecção",
|
||||
"unknown": "Desconhecido"
|
||||
"unknown": "Desconhecido",
|
||||
"credentialId": "Credencial ID"
|
||||
},
|
||||
"dragIndicator": {
|
||||
"error": "Erro: {{error}}",
|
||||
@@ -191,6 +190,20 @@
|
||||
"enableRightClickCopyPaste": "Habilitar copiar/colar com botão direito",
|
||||
"shareIdeas": "Tem ideias sobre o que deve vir a seguir nas ferramentas SSH? Compartilhe em"
|
||||
},
|
||||
"commandHistory": {
|
||||
"title": "Histórico",
|
||||
"searchPlaceholder": "Pesquisar comandos...",
|
||||
"noTerminal": "Nenhum terminal ativo",
|
||||
"noTerminalHint": "Abra um terminal para ver seu histórico de comandos.",
|
||||
"empty": "Ainda não há histórico de comandos",
|
||||
"emptyHint": "Execute comandos no terminal ativo para criar um histórico.",
|
||||
"noResults": "Nenhum comando encontrado",
|
||||
"noResultsHint": "Nenhum comando correspondente a \"{{query}}\"",
|
||||
"deleteSuccess": "Comando removido do histórico",
|
||||
"deleteFailed": "Falha ao excluir comeo.",
|
||||
"deleteTooltip": "Excluir comando",
|
||||
"tabHint": "Use Tab no Terminal para autocompletar do histórico de comandos"
|
||||
},
|
||||
"homepage": {
|
||||
"loggedInTitle": "Conectado!",
|
||||
"loggedInMessage": "Você está conectado! Use a barra lateral para acessar todas as ferramentas disponíveis. Para começar, crie um Host SSH na aba Gerenciador SSH. Depois de criado, você pode se conectar a esse host usando os outros apps na barra lateral.",
|
||||
@@ -205,21 +218,25 @@
|
||||
"testConnectionFirst": "Por favor, teste a conexão primeiro",
|
||||
"connectionSuccess": "Conexão bem-sucedida!",
|
||||
"connectionFailed": "Conexão falhou",
|
||||
"connectionError": "Ocorreu um erro de conexão",
|
||||
"connectionError": "Ocoureu um erro de conexão",
|
||||
"connected": "Conectado",
|
||||
"disconnected": "Desconectado",
|
||||
"configSaved": "Configuração salva com sucesso",
|
||||
"saveFailed": "Falha ao salvar configuração",
|
||||
"saveError": "Erro ao salvar configuração",
|
||||
"saving": "Salvando...",
|
||||
"saving": "Salveo...",
|
||||
"saveConfig": "Salvar Configuração",
|
||||
"helpText": "Digite a URL onde seu servidor Termix está rodando (ex.: http://localhost:30001 ou https://seu-servidor.com)"
|
||||
"helpText": "Digite a URL onde seu servidor Termix está rodando (ex.: http://localhost:30001 ou https://seu-servidor.com)",
|
||||
"changeServer": "Alterar Servidor",
|
||||
"mustIncludeProtocol": "URL do Servidor deve começar com http:// ou https://",
|
||||
"notValidatedWarning": "URL não validada - verifique se está correta",
|
||||
"warning": "Aviso"
|
||||
},
|
||||
"versionCheck": {
|
||||
"error": "Erro na verificação de versão",
|
||||
"checkFailed": "Falha ao verificar atualizações",
|
||||
"upToDate": "Aplicativo atualizado",
|
||||
"currentVersion": "Você está usando a versão {{version}}",
|
||||
"currentVersion": "Você está useo a versão {{version}}",
|
||||
"updateAvailable": "Atualização disponível",
|
||||
"newVersionAvailable": "Uma nova versão está disponível! Você está usando {{current}}, mas {{latest}} está disponível.",
|
||||
"releasedOn": "Lançada em {{date}}",
|
||||
@@ -241,12 +258,12 @@
|
||||
"continue": "Continuar",
|
||||
"maintenance": "Manutenção",
|
||||
"degraded": "Degradado",
|
||||
"discord": "Discord",
|
||||
"discord": "Discoud",
|
||||
"error": "Erro",
|
||||
"warning": "Aviso",
|
||||
"info": "Info",
|
||||
"success": "Sucesso",
|
||||
"loading": "Carregando",
|
||||
"loading": "Carregando...",
|
||||
"required": "Obrigatório",
|
||||
"optional": "Opcional",
|
||||
"clear": "Limpar",
|
||||
@@ -260,14 +277,13 @@
|
||||
"updateAvailable": "Atualização Disponível",
|
||||
"sshPath": "Caminho SSH",
|
||||
"localPath": "Caminho Local",
|
||||
"loading": "Carregando...",
|
||||
"noAuthCredentials": "Não há credenciais de autenticação disponíveis para este host SSH",
|
||||
"noReleases": "Sem Versões",
|
||||
"updatesAndReleases": "Atualizações e Versões",
|
||||
"newVersionAvailable": "Uma nova versão ({{version}}) está disponível.",
|
||||
"failedToFetchUpdateInfo": "Falha ao buscar informações de atualização",
|
||||
"preRelease": "Pré-lançamento",
|
||||
"loginFailed": "Falha no login",
|
||||
"loginFailed": "Falha nenhum login",
|
||||
"noReleasesFound": "Nenhuma versão encontrada.",
|
||||
"yourBackupCodes": "Seus Códigos de Backup",
|
||||
"sendResetCode": "Enviar Código de Redefinição",
|
||||
@@ -275,13 +291,10 @@
|
||||
"resetPassword": "Redefinir Senha",
|
||||
"resetCode": "Código de Redefinição",
|
||||
"newPassword": "Nova Senha",
|
||||
"sshPath": "Caminho SSH",
|
||||
"localPath": "Caminho Local",
|
||||
"folder": "Pasta",
|
||||
"file": "Arquivo",
|
||||
"renamedSuccessfully": "renomeado com sucesso",
|
||||
"deletedSuccessfully": "excluído com sucesso",
|
||||
"noAuthCredentials": "Não há credenciais de autenticação disponíveis para este host SSH",
|
||||
"noTunnelConnections": "Não há conexões de túnel configuradas",
|
||||
"sshTools": "Ferramentas SSH",
|
||||
"english": "Inglês",
|
||||
@@ -293,36 +306,27 @@
|
||||
"login": "Entrar",
|
||||
"logout": "Sair",
|
||||
"register": "Registrar",
|
||||
"username": "Usuário",
|
||||
"password": "Senha",
|
||||
"version": "Versão",
|
||||
"confirmPassword": "Confirmar Senha",
|
||||
"back": "Voltar",
|
||||
"email": "Email",
|
||||
"submit": "Enviar",
|
||||
"cancel": "Cancelar",
|
||||
"change": "Alterar",
|
||||
"save": "Salvar",
|
||||
"delete": "Excluir",
|
||||
"edit": "Editar",
|
||||
"add": "Adicionar",
|
||||
"search": "Buscar",
|
||||
"loading": "Carregando...",
|
||||
"error": "Erro",
|
||||
"success": "Sucesso",
|
||||
"warning": "Aviso",
|
||||
"info": "Info",
|
||||
"confirm": "Confirmar",
|
||||
"yes": "Sim",
|
||||
"no": "Não",
|
||||
"ok": "OK",
|
||||
"close": "Fechar",
|
||||
"enabled": "Habilitado",
|
||||
"disabled": "Desabilitado",
|
||||
"important": "Importante",
|
||||
"notEnabled": "Não Habilitado",
|
||||
"settingUp": "Configurando...",
|
||||
"back": "Voltar",
|
||||
"next": "Próximo",
|
||||
"previous": "Anterior",
|
||||
"refresh": "Atualizar",
|
||||
@@ -339,7 +343,7 @@
|
||||
"passwordResetDescription": "Você está prestes a redefinir sua senha. Isso fará com que você seja desconectado de todas as sessões ativas.",
|
||||
"enterSixDigitCode": "Digite o código de 6 dígitos dos logs do container docker para o usuário:",
|
||||
"enterNewPassword": "Digite sua nova senha para o usuário:",
|
||||
"passwordsDoNotMatch": "As senhas não correspondem",
|
||||
"passwordsDoNotMatch": "As senhas não courespondem",
|
||||
"passwordMinLength": "A senha deve ter pelo menos 6 caracteres",
|
||||
"passwordResetSuccess": "Senha redefinida com sucesso! Você pode agora entrar com sua nova senha.",
|
||||
"failedToInitiatePasswordReset": "Falha ao iniciar redefinição de senha",
|
||||
@@ -348,7 +352,8 @@
|
||||
"documentation": "Documentação",
|
||||
"retry": "Tentar Novamente",
|
||||
"checking": "Verificando...",
|
||||
"checkingDatabase": "Verificando conexão com o banco de dados..."
|
||||
"checkingDatabase": "Verificando conexão com o banco de dados...",
|
||||
"saving": "Salveo..."
|
||||
},
|
||||
"nav": {
|
||||
"home": "Início",
|
||||
@@ -358,7 +363,7 @@
|
||||
"tunnels": "Túneis",
|
||||
"fileManager": "Gerenciador de Arquivos",
|
||||
"serverStats": "Estatísticas do Servidor",
|
||||
"admin": "Admin",
|
||||
"admin": "Administrador",
|
||||
"userProfile": "Perfil do Usuário",
|
||||
"tools": "Ferramentas",
|
||||
"newTab": "Nova Aba",
|
||||
@@ -367,15 +372,16 @@
|
||||
"sshManager": "Gerenciador SSH",
|
||||
"hostManager": "Gerenciador de Hosts",
|
||||
"cannotSplitTab": "Não é possível dividir esta aba",
|
||||
"tabNavigation": "Navegação de Abas"
|
||||
"tabNavigation": "Navegação de Abas",
|
||||
"snippets": "Snippets"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Configurações de Admin",
|
||||
"title": "Configurações de Administrador",
|
||||
"oidc": "OIDC",
|
||||
"users": "Usuários",
|
||||
"userManagement": "Gerenciamento de Usuários",
|
||||
"makeAdmin": "Tornar Admin",
|
||||
"removeAdmin": "Remover Admin",
|
||||
"makeAdmin": "Tornar Administrador",
|
||||
"removeAdmin": "Remover Administrador",
|
||||
"deleteUser": "Excluir Usuário",
|
||||
"allowRegistration": "Permitir Registro",
|
||||
"oidcSettings": "Configurações OIDC",
|
||||
@@ -386,11 +392,11 @@
|
||||
"tokenUrl": "URL do Token",
|
||||
"updateSettings": "Atualizar Configurações",
|
||||
"confirmDelete": "Tem certeza que deseja excluir este usuário?",
|
||||
"confirmMakeAdmin": "Tem certeza que deseja tornar este usuário um admin?",
|
||||
"confirmRemoveAdmin": "Tem certeza que deseja remover os privilégios de admin deste usuário?",
|
||||
"confirmMakeAdmin": "Tem certeza que deseja tornar este usuário um administrador?",
|
||||
"confirmRemoveAdmin": "Tem certeza que deseja remover os privilégios de administrador deste usuário?",
|
||||
"externalAuthentication": "Autenticação Externa (OIDC)",
|
||||
"configureExternalProvider": "Configure o provedor de identidade externo para autenticação OIDC/OAuth2.",
|
||||
"userIdentifierPath": "Caminho do Identificador do Usuário",
|
||||
"userIdentifierPath": "Caminho do Identificadou do Usuário",
|
||||
"displayNamePath": "Caminho do Nome de Exibição",
|
||||
"scopes": "Escopos",
|
||||
"saving": "Salvando...",
|
||||
@@ -405,12 +411,12 @@
|
||||
"actions": "Ações",
|
||||
"external": "Externo",
|
||||
"local": "Local",
|
||||
"adminManagement": "Gerenciamento de Admin",
|
||||
"makeUserAdmin": "Tornar Usuário Admin",
|
||||
"adminManagement": "Gerenciamento de Administrador",
|
||||
"makeUserAdmin": "Tornar Usuário Administrador",
|
||||
"adding": "Adicionando...",
|
||||
"currentAdmins": "Admins Atuais",
|
||||
"adminBadge": "Admin",
|
||||
"removeAdminButton": "Remover Admin",
|
||||
"currentAdmins": "Administradores Atuais",
|
||||
"adminBadge": "Administrador",
|
||||
"removeAdminButton": "Remover Administrador",
|
||||
"general": "Geral",
|
||||
"userRegistration": "Registro de Usuário",
|
||||
"allowNewAccountRegistration": "Permitir registro de novas contas",
|
||||
@@ -422,13 +428,12 @@
|
||||
"oidcConfigurationDisabled": "Configuração OIDC desativada com sucesso!",
|
||||
"failedToUpdateOidcConfig": "Falha ao atualizar configuração OIDC",
|
||||
"failedToDisableOidcConfig": "Falha ao desativar configuração OIDC",
|
||||
"enterUsernameToMakeAdmin": "Insira o nome de usuário para tornar admin",
|
||||
"enterUsernameToMakeAdmin": "Insira o nome de usuário para tornar administrador",
|
||||
"userIsNowAdmin": "O usuário {{username}} agora é um administrador",
|
||||
"failedToMakeUserAdmin": "Falha ao tornar o usuário administrador",
|
||||
"removeAdminStatus": "Remover status de administrador de {{username}}?",
|
||||
"adminStatusRemoved": "Status de administrador removido de {{username}}",
|
||||
"failedToRemoveAdminStatus": "Falha ao remover o status de administrador",
|
||||
"confirmDeleteUser": "Excluir usuário {{username}}? Esta ação não pode ser desfeita.",
|
||||
"userDeletedSuccessfully": "Usuário {{username}} excluído com sucesso",
|
||||
"failedToDeleteUser": "Falha ao excluir usuário",
|
||||
"overrideUserInfoUrl": "Sobrescrever URL de informações do usuário (não obrigatório)",
|
||||
@@ -481,7 +486,6 @@
|
||||
"verificationCompleted": "Verificação de compatibilidade concluída - nenhum dado foi alterado",
|
||||
"verificationInProgress": "Verificação concluída",
|
||||
"dataMigrationCompleted": "Migração de dados concluída com sucesso!",
|
||||
"migrationCompleted": "Migração concluída",
|
||||
"verificationFailed": "Falha na verificação de compatibilidade",
|
||||
"migrationFailed": "Falha na migração",
|
||||
"runningVerification": "Executando verificação de compatibilidade...",
|
||||
@@ -526,13 +530,13 @@
|
||||
"databaseImportFailed": "Falha na importação do banco de dados SQLite",
|
||||
"manageEncryptionAndBackups": "Gerenciar chaves de criptografia, segurança do banco de dados e operações de backup",
|
||||
"activeSecurityFeatures": "Medidas e proteções de segurança atualmente ativas",
|
||||
"deviceBindingTechnology": "Tecnologia avançada de proteção de chave baseada em hardware",
|
||||
"deviceBindingTechnology": "Tecnenhumlogia avançada de proteção de chave baseada em hardware",
|
||||
"backupAndRecovery": "Opções seguras de criação de backup e recuperação de banco de dados",
|
||||
"crossSystemDataTransfer": "Exportar e importar bancos de dados entre diferentes sistemas",
|
||||
"noMigrationNeeded": "Nenhuma migração necessária",
|
||||
"encryptionKey": "Chave de Criptografia",
|
||||
"keyProtection": "Proteção da Chave",
|
||||
"active": "Ativo",
|
||||
"active": "Ativa",
|
||||
"legacy": "Legado",
|
||||
"dataStatus": "Status dos Dados",
|
||||
"encrypted": "Criptografado",
|
||||
@@ -562,13 +566,38 @@
|
||||
"confirmDisableOIDCWarning": "AVISO: Você está prestes a desativar o OIDC enquanto o login por senha também está desativado. Isso inutilizará sua instância do Termix e você perderá todo o acesso. Tem absoluta certeza de que deseja continuar?",
|
||||
"allowPasswordLogin": "Permitir login com nome de usuário/senha",
|
||||
"failedToFetchPasswordLoginStatus": "Falha ao buscar status do login por senha",
|
||||
"failedToUpdatePasswordLoginStatus": "Falha ao atualizar status do login por senha"
|
||||
"failedToUpdatePasswordLoginStatus": "Falha ao atualizar status do login por senha",
|
||||
"accountsLinkedSuccessfully": "Usuário OIDC {{oidcUsername}} foi vinculado a {{targetUsername}}",
|
||||
"confirmRevokeAllSessions": "Tem certeza de que deseja revogar todas as sessões para este usuário?",
|
||||
"confirmRevokeSession": "Tem certeza de que deseja revogar esta sessão?",
|
||||
"failedToFetchSessions": "Falha ao buscar sessões",
|
||||
"failedToLinkAccounts": "Falha ao vincular contas",
|
||||
"failedToRevokeSession": "Falha ao revogar sessão",
|
||||
"failedToRevokeSessions": "Falha ao revogar sessões",
|
||||
"failedToUnlinkOIDC": "Falha ao desvincular OIDC",
|
||||
"linkAccountsButton": "Vincular Contas",
|
||||
"linkOIDCActionAddCapability": "Adicionar capacidade de login OIDC à conta de senha de destino",
|
||||
"linkOIDCActionDeleteUser": "Excluir a conta de usuário OIDC e todos os outros dados",
|
||||
"linkOIDCActionDualAuth": "Permitir que a conta de senha faça login com senha e OIDC",
|
||||
"linkOIDCDialogDescription": "Vincular {{username}} (usuário OIDC) a uma conta de senha existente. Isso ativará autenticação dupla para a conta de senha.",
|
||||
"linkOIDCDialogTitle": "Link OIDC Account para Senha Account",
|
||||
"linkOIDCWarningTitle": "Aviso: Dados do Usuário OIDC Serão Deletados",
|
||||
"linkTargetUsernameLabel": "Nome de usuário da conta de senha de destino",
|
||||
"linkTargetUsernamePlaceholder": "Inserir nome de usuário da conta de senha",
|
||||
"linkTargetUsernameRequired": "Nome de usuário de destino é obrigatório",
|
||||
"linkToPasswordAccount": "Vincular à Conta de Senha",
|
||||
"linkingAccounts": "Vinculando...",
|
||||
"sessionRevokedSuccessfully": "Sessão revogada com sucesso",
|
||||
"sessionsRevokedSuccessfully": "Sessões revogadas com sucesso",
|
||||
"unlinkOIDCDescription": "Remover autenticação OIDC de {{username}}? O usuário só poderá fazer login com nome de usuário/senha após isso.",
|
||||
"unlinkOIDCSuccess": "OIDC desvinculado de {{username}}",
|
||||
"unlinkOIDCTitle": "Desvincular Autenticação OIDC"
|
||||
},
|
||||
"hosts": {
|
||||
"title": "Gerenciador de Hosts",
|
||||
"sshHosts": "Hosts SSH",
|
||||
"noHosts": "Sem Hosts SSH",
|
||||
"noHostsMessage": "Você ainda não adicionou nenhum host SSH. Clique em \"Adicionar Host\" para começar.",
|
||||
"noHostsMessage": "Você ainda não adicionenhumu nenhum host SSH. Clique em \"Adicionar Host\" para começar.",
|
||||
"loadingHosts": "Carregando hosts...",
|
||||
"failedToLoadHosts": "Falha ao carregar hosts",
|
||||
"retry": "Tentar Novamente",
|
||||
@@ -582,12 +611,12 @@
|
||||
"formatGuide": "Guia de Formato",
|
||||
"exportCredentialWarning": "Aviso: O host \"{{name}}\" usa autenticação por credencial. O arquivo exportado não incluirá os dados da credencial e precisará ser reconfigurado manualmente após a importação. Deseja continuar?",
|
||||
"exportSensitiveDataWarning": "Aviso: O host \"{{name}}\" contém dados de autenticação sensíveis (senha/chave SSH). O arquivo exportado incluirá esses dados em texto simples. Mantenha o arquivo seguro e exclua-o após o uso. Deseja continuar?",
|
||||
"uncategorized": "Sem categoria",
|
||||
"uncategorized": "Sem categouia",
|
||||
"confirmDelete": "Tem certeza que deseja excluir \"{{name}}\"?",
|
||||
"failedToDeleteHost": "Falha ao excluir host",
|
||||
"failedToExportHost": "Falha ao exportar host. Certifique-se de que está logado e tem acesso aos dados do host.",
|
||||
"jsonMustContainHosts": "O JSON deve conter um array \"hosts\" ou ser um array de hosts",
|
||||
"noHostsInJson": "Nenhum host encontrado no arquivo JSON",
|
||||
"noHostsInJson": "Nenhum host encontrado nenhum arquivo JSON",
|
||||
"maxHostsAllowed": "Máximo de 100 hosts permitidos por importação",
|
||||
"importCompleted": "Importação concluída: {{success}} com sucesso, {{failed}} falhas",
|
||||
"importFailed": "Falha na importação",
|
||||
@@ -597,7 +626,7 @@
|
||||
"organization": "Organização",
|
||||
"ipAddress": "Endereço IP",
|
||||
"port": "Porta",
|
||||
"name": "Nome",
|
||||
"name": "Nenhumme",
|
||||
"username": "Usuário",
|
||||
"folder": "Pasta",
|
||||
"tags": "Tags",
|
||||
@@ -618,13 +647,13 @@
|
||||
"enableTerminalDesc": "Habilitar/desabilitar visibilidade do host na aba Terminal",
|
||||
"enableTunnel": "Habilitar Túnel",
|
||||
"enableTunnelDesc": "Habilitar/desabilitar visibilidade do host na aba Túnel",
|
||||
"enableFileManager": "Habilitar Gerenciador de Arquivos",
|
||||
"enableFileManagerDesc": "Habilitar/desabilitar visibilidade do host na aba Gerenciador de Arquivos",
|
||||
"enableFileManager": "Habilitar Gerenciadou de Arquivos",
|
||||
"enableFileManagerDesc": "Habilitar/desabilitar visibilidade do host na aba Gerenciadou de Arquivos",
|
||||
"defaultPath": "Caminho Padrão",
|
||||
"defaultPathDesc": "Diretório padrão ao abrir o gerenciador de arquivos para este host",
|
||||
"defaultPathDesc": "Diretório padrão ao abrir o gerenciadou de arquivos para este host",
|
||||
"tunnelConnections": "Conexões de Túnel",
|
||||
"connection": "Conexão",
|
||||
"remove": "Remover",
|
||||
"remove": "Removerr",
|
||||
"sourcePort": "Porta de Origem",
|
||||
"sourcePortDesc": "(Source refere-se aos Detalhes da Conexão Atual na aba Geral)",
|
||||
"endpointPort": "Porta de Destino",
|
||||
@@ -681,12 +710,12 @@
|
||||
"addTagsSpaceToAdd": "adicionar tags (espaço para adicionar)",
|
||||
"terminalBadge": "Terminal",
|
||||
"tunnelBadge": "Túnel",
|
||||
"fileManagerBadge": "Gerenciador de Arquivos",
|
||||
"fileManagerBadge": "Gerenciadou de Arquivos",
|
||||
"general": "Geral",
|
||||
"terminal": "Terminal",
|
||||
"tunnel": "Túnel",
|
||||
"fileManager": "Gerenciador de Arquivos",
|
||||
"hostViewer": "Visualizador de Host",
|
||||
"fileManager": "Gerenciadou de Arquivos",
|
||||
"hostViewer": "Visualizadou de Host",
|
||||
"confirmRemoveFromFolder": "Tem certeza que deseja remover \"{{name}}\" da pasta \"{{folder}}\"? O host será movido para \"Sem Pasta\".",
|
||||
"removedFromFolder": "Host \"{{name}}\" removido da pasta com sucesso",
|
||||
"failedToRemoveFromFolder": "Falha ao remover host da pasta",
|
||||
@@ -720,7 +749,116 @@
|
||||
"noneAuthDescription": "Este método de autenticação usará autenticação interativa por teclado ao conectar ao servidor SSH.",
|
||||
"noneAuthDetails": "A autenticação interativa por teclado permite que o servidor solicite credenciais durante a conexão. Isso é útil para servidores que requerem autenticação multifator ou entrada de senha dinâmica.",
|
||||
"forceKeyboardInteractive": "Forçar Interativo com Teclado",
|
||||
"forceKeyboardInteractiveDesc": "Força o uso da autenticação interativa com teclado. Isso é frequentemente necessário para servidores que usam Autenticação de Dois Fatores (TOTP/2FA)."
|
||||
"forceKeyboardInteractiveDesc": "Força o uso da autenticação interativa com teclado. Isso é frequentemente necessário para servidores que usam Autenticação de Dois Fatores (TOTP/2FA).",
|
||||
"overrideCredentialUsername": "Substituir Nome de Usuário da Credencial",
|
||||
"overrideCredentialUsernameDesc": "Use um nome de usuário diferente daquele armazenado na credencial. Isso permite que você use a mesma credencial com diferentes nomes de usuário.",
|
||||
"jumpHosts": "Hosts de Salto",
|
||||
"jumpHostsDescription": "Hosts de salto (também conhecidos como bastions) permitem que você se conecte a um servidor de destino através de um ou mais servidores intermediários. Isso é útil para acessar servidores atrás de firewalls ou em redes privadas.",
|
||||
"jumpHostChain": "Cadeia de Hosts de Salto",
|
||||
"addJumpHost": "Adicionar Host de Salto",
|
||||
"selectServer": "Selecionar Servidor",
|
||||
"searchServers": "Pesquisar servidores...",
|
||||
"noServerFound": "Nenhum servidor encontrado",
|
||||
"jumpHostsOrder": "As conexões serão feitas na ordem: Host de Salto 1 → Host de Salto 2 → ... → Servidor de Destino",
|
||||
"advancedAuthSettings": "Configurações Avançadas de Autenticação",
|
||||
"addQuickAction": "Adicionar Ação Rápida",
|
||||
"adjustFontSize": "Ajustar o tamanho da fonte do terminal",
|
||||
"adjustLetterSpacing": "Ajustar espaçamento entre caracteres",
|
||||
"adjustLineHeight": "Ajustar espaçamento entre linhas",
|
||||
"advanced": "Avançado",
|
||||
"allHostsInFolderDeleted": "Excluídos {{count}} hosts da pasta \"{{folder}}\" com sucesso",
|
||||
"appearance": "Aparência",
|
||||
"backspaceMode": "Modo Backspace",
|
||||
"backspaceModeControlH": "Control-H (^H)",
|
||||
"backspaceModeDesc": "Comportamento da tecla Backspace para compatibilidade",
|
||||
"backspaceModeNormal": "Normal (DEL)",
|
||||
"behavior": "Comportamento",
|
||||
"bellStyle": "Estilo de Campainha",
|
||||
"bellStyleBoth": "Both",
|
||||
"bellStyleDesc": "Como lidar com a campainha do terminal (caractere BEL, \\x07). Programas acionam isso ao completar tarefas, encontrar erros, ou para notificações. \"Som\" toca um bipe de áudio, \"Visual\" pisca a tela brevemente, \"Ambos\" faz os dois, \"Nenhum\" desativa os alertas de campainha.",
|
||||
"bellStyleNone": "Nenhum",
|
||||
"bellStyleSound": "Som",
|
||||
"bellStyleVisual": "Visual",
|
||||
"chooseColorTheme": "Escolha um tema de cor para o terminal",
|
||||
"chooseCursorAppearance": "Escolha a aparência do cursor",
|
||||
"confirmDeleteAllHostsInFolder": "Tem certeza de que deseja excluir todos os {{count}} hosts na pasta \"{{folder}}\"? Esta ação não pode ser desfeita.",
|
||||
"cursorBlink": "Piscar Cursor",
|
||||
"cursorStyle": "Estilo do Cursor",
|
||||
"cursorStyleBar": "Barra",
|
||||
"cursorStyleBlock": "Bloco",
|
||||
"cursorStyleUnderline": "Sublinhado",
|
||||
"customCommands": "Comandos Personalizados (Em Breve)",
|
||||
"customCommandsDesc": "Defina comandos personalizados de desligamento e reinicialização para este servidor",
|
||||
"deleteAllHostsInFolder": "Excluir Todos os Hosts na Pasta",
|
||||
"displayItems": "Exibir Itens",
|
||||
"displayItemsDesc": "Escolha quais métricas exibir na página de estatísticas do servidor",
|
||||
"editFolderAppearance": "Editar Aparência da Pasta",
|
||||
"editFolderAppearanceDesc": "Personalize a cor e o ícone da pasta",
|
||||
"enableCpu": "Uso da CPU",
|
||||
"enableCursorBlink": "Ativar animação de piscar do cursor",
|
||||
"enableDisk": "Uso de Disco",
|
||||
"enableHostname": "Hostname (Em Breve)",
|
||||
"enableMemory": "Uso de Memória",
|
||||
"enableNetwork": "Estatísticas de Rede (Em Breve)",
|
||||
"enableOs": "Sistema Operacional (Em Breve)",
|
||||
"enableProcesses": "Contagem de Processos (Em Breve)",
|
||||
"enableServerStats": "Ativar Estatísticas do Servidor",
|
||||
"enableServerStatsDesc": "Ativar/desativar coleta de estatísticas do servidor para este host",
|
||||
"enableUptime": "Tempo de Atividade (Em Breve)",
|
||||
"failedToDeleteHostsInFolder": "Falha ao excluir hosts na pasta",
|
||||
"failedToUpdateFolderAppearance": "Falha ao atualizar aparência da pasta",
|
||||
"fastScrollModifier": "Modificador de Rolagem Rápida",
|
||||
"fastScrollModifierDesc": "Tecla modificadora para rolagem rápida",
|
||||
"fastScrollSensitivity": "Sensibilidade de Rolagem Rápida",
|
||||
"fastScrollSensitivityDesc": "Multiplicador de velocidade de rolagem quando modificador é segurado",
|
||||
"fastScrollSensitivityValue": "Sensibilidade de Rolagem Rápida: {{value}}",
|
||||
"folderAppearanceUpdated": "Aparência da pasta atualizada com sucesso",
|
||||
"folderColor": "Cor da Pasta",
|
||||
"folderIcon": "Ícone da Pasta",
|
||||
"fontFamily": "Família da Fonte",
|
||||
"fontSize": "Tamanho da Fonte",
|
||||
"fontSizeValue": "Tamanho da Fonte: {{value}}px",
|
||||
"letterSpacing": "Espaçamento entre Letras",
|
||||
"letterSpacingValue": "Espaçamento entre Letras: {{value}}px",
|
||||
"lineHeight": "Altura da Linha",
|
||||
"lineHeightValue": "Altura da Linha: {{value}}",
|
||||
"minimumContrastRatio": "Taxa de Contraste Mínima",
|
||||
"minimumContrastRatioDesc": "Ajustar cores automaticamente para melhor legibilidade",
|
||||
"minimumContrastRatioValue": "Taxa de Contraste Mínima: {{value}}",
|
||||
"modifierAlt": "Alt",
|
||||
"modifierCtrl": "Ctrl",
|
||||
"modifierShift": "Shift",
|
||||
"noSnippetFound": "Nenhum snippet encontrado",
|
||||
"preview": "Pré-visualização",
|
||||
"quickActionName": "Nome da Ação",
|
||||
"quickActions": "Ações Rápidas",
|
||||
"quickActionsDescription": "Ações rápidas permitem criar botões personalizados que executam snippets SSH neste servidor. Esses botões aparecerão no topo da página de Estatísticas do Servidor para acesso rápido.",
|
||||
"quickActionsList": "Lista de Ações Rápidas",
|
||||
"quickActionsOrder": "Botões de ação rápida aparecerão na ordem listada acima na página de Estatísticas do Servidor",
|
||||
"rebootCommand": "Comando de Reinicialização",
|
||||
"rightClickSelectsWord": "Clique Direito Seleciona Palavra",
|
||||
"rightClickSelectsWordDesc": "Clicar com o botão direito seleciona a palavra sob o cursor",
|
||||
"scrollbackBuffer": "Histórico de Rolagem",
|
||||
"scrollbackBufferDesc": "Número de linhas para manter no histórico de rolagem",
|
||||
"scrollbackBufferValue": "Histórico de Rolagem: {{value}} linhas",
|
||||
"searchSnippets": "Pesquisar snippets...",
|
||||
"selectBackspaceMode": "Selecionar modo backspace",
|
||||
"selectBellStyle": "Selecionar estilo de campainha",
|
||||
"selectCursorStyle": "Selecionar estilo de cursor",
|
||||
"selectFont": "Selecionar fonte",
|
||||
"selectFontDesc": "Selecione a fonte para usar no terminal",
|
||||
"selectModifier": "Selecionar modificador",
|
||||
"selectSnippet": "Selecionar snippet",
|
||||
"selectTheme": "Selecionar tema",
|
||||
"serverStats": "Estatísticas do Servidor",
|
||||
"shutdownCommand": "Comando de Desligamento",
|
||||
"snippetNone": "Nenhum",
|
||||
"sshAgentForwarding": "Encaminhamento de Agente SSH",
|
||||
"sshAgentForwardingDesc": "Encaminhar agente de autenticação SSH para host remoto",
|
||||
"startupSnippet": "Snippet de Inicialização",
|
||||
"terminalCustomization": "Personalização do Terminal",
|
||||
"theme": "Tema",
|
||||
"themePreview": "Pré-visualização do Tema"
|
||||
},
|
||||
"terminal": {
|
||||
"title": "Terminal",
|
||||
@@ -754,7 +892,11 @@
|
||||
"connectionTimeout": "Tempo limite de conexão esgotado",
|
||||
"terminalTitle": "Terminal - {{host}}",
|
||||
"terminalWithPath": "Terminal - {{host}}:{{path}}",
|
||||
"runTitle": "Executando {{command}} - {{host}}"
|
||||
"runTitle": "Executando {{command}} - {{host}}",
|
||||
"totpCodeLabel": "Código de Verificação",
|
||||
"totpPlaceholder": "000000",
|
||||
"totpRequired": "Autenticação de Dois Fatores Obrigatória",
|
||||
"totpVerify": "Verificar"
|
||||
},
|
||||
"fileManager": {
|
||||
"title": "Gerenciador de Arquivos",
|
||||
@@ -836,9 +978,10 @@
|
||||
"copy": "Copiar",
|
||||
"cut": "Recortar",
|
||||
"paste": "Colar",
|
||||
"copyPath": "Copiar caminho",
|
||||
"copyPaths": "Copiar caminhos",
|
||||
"delete": "Excluir",
|
||||
"properties": "Propriedades",
|
||||
"preview": "Visualizar",
|
||||
"refresh": "Atualizar",
|
||||
"downloadFiles": "Baixar {{count}} arquivos para o Navegador",
|
||||
"copyFiles": "Copiar {{count}} itens",
|
||||
@@ -846,22 +989,18 @@
|
||||
"deleteFiles": "Excluir {{count}} itens",
|
||||
"filesCopiedToClipboard": "{{count}} itens copiados para a área de transferência",
|
||||
"filesCutToClipboard": "{{count}} itens recortados para a área de transferência",
|
||||
"pathCopiedToClipboard": "Caminho copiado para a área de transferência",
|
||||
"pathsCopiedToClipboard": "{{count}} caminhos copiados para a área de transferência",
|
||||
"failedToCopyPath": "Falha ao copiar caminho para a área de transferência",
|
||||
"movedItems": "{{count}} itens movidos",
|
||||
"failedToDeleteItem": "Falha ao excluir item",
|
||||
"itemRenamedSuccessfully": "{{type}} renomeado com sucesso",
|
||||
"failedToRenameItem": "Falha ao renomear item",
|
||||
"upload": "Enviar",
|
||||
"download": "Baixar",
|
||||
"newFile": "Novo Arquivo",
|
||||
"newFolder": "Nova Pasta",
|
||||
"rename": "Renomear",
|
||||
"delete": "Excluir",
|
||||
"permissions": "Permissões",
|
||||
"size": "Tamanho",
|
||||
"modified": "Modificado",
|
||||
"path": "Caminho",
|
||||
"fileName": "Nome do Arquivo",
|
||||
"folderName": "Nome da Pasta",
|
||||
"confirmDelete": "Tem certeza que deseja excluir {{name}}?",
|
||||
"uploadSuccess": "Arquivo enviado com sucesso",
|
||||
"uploadFailed": "Falha ao enviar arquivo",
|
||||
@@ -881,10 +1020,7 @@
|
||||
"fileSavedSuccessfully": "Arquivo salvo com sucesso",
|
||||
"saveTimeout": "Tempo limite da operação de salvamento esgotado. O arquivo pode ter sido salvo com sucesso, mas a operação demorou muito para ser concluída. Verifique os logs do Docker para confirmação.",
|
||||
"failedToSaveFile": "Falha ao salvar arquivo",
|
||||
"folder": "Pasta",
|
||||
"file": "Arquivo",
|
||||
"deletedSuccessfully": "excluído com sucesso",
|
||||
"failedToDeleteItem": "Falha ao excluir item",
|
||||
"connectToServer": "Conectar a um Servidor",
|
||||
"selectServerToEdit": "Selecione um servidor da barra lateral para começar a editar arquivos",
|
||||
"fileOperations": "Operações de Arquivo",
|
||||
@@ -913,7 +1049,7 @@
|
||||
"sshReconnectionTimeout": "Tempo limite excedido na reconexão SSH",
|
||||
"saveOperationTimeout": "Tempo limite excedido na operação de salvar",
|
||||
"cannotSaveFile": "Não é possível salvar o arquivo",
|
||||
"dragSystemFilesToUpload": "Arraste arquivos do sistema aqui para fazer upload",
|
||||
"dragSystemFilesToUpload": "Arraste arquivos do sistema aqui para enviar",
|
||||
"dragFilesToWindowToDownload": "Arraste arquivos para fora da janela para baixar",
|
||||
"openTerminalHere": "Abrir Terminal Aqui",
|
||||
"run": "Executar",
|
||||
@@ -941,14 +1077,12 @@
|
||||
"unpinFile": "Desfixar arquivo",
|
||||
"removeShortcut": "Remover atalho",
|
||||
"saveFilesToSystem": "Salvar {{count}} arquivos como...",
|
||||
"saveToSystem": "Salvar como...",
|
||||
"pinFile": "Fixar arquivo",
|
||||
"addToShortcuts": "Adicionar aos atalhos",
|
||||
"selectLocationToSave": "Selecionar local para salvar",
|
||||
"downloadToDefaultLocation": "Baixar para o local padrão",
|
||||
"pasteFailed": "Falha ao colar",
|
||||
"noUndoableActions": "Nenhuma ação pode ser desfeita",
|
||||
"undoCopySuccess": "Operação de cópia desfeita: {{count}} arquivos copiados foram excluídos",
|
||||
"undoCopySuccess": "oOperação de cópia desfeita: {{count}} arquivos copiados foram excluídos",
|
||||
"undoCopyFailedDelete": "Falha ao desfazer: Não foi possível excluir os arquivos copiados",
|
||||
"undoCopyFailedNoInfo": "Falha ao desfazer: Não foi possível encontrar informações do arquivo copiado",
|
||||
"undoMoveSuccess": "Operação de mover desfeita: {{count}} arquivos movidos de volta ao local original",
|
||||
@@ -959,10 +1093,9 @@
|
||||
"undoOperationFailed": "Falha na operação de desfazer",
|
||||
"unknownError": "Erro desconhecido",
|
||||
"enterPath": "Digite o caminho...",
|
||||
"editPath": "Editar caminho",
|
||||
"editPath": "Editarar caminho",
|
||||
"confirm": "Confirmar",
|
||||
"cancel": "Cancelar",
|
||||
"folderName": "Nome da pasta",
|
||||
"find": "Localizar...",
|
||||
"replaceWith": "Substituir por...",
|
||||
"replace": "Substituir",
|
||||
@@ -986,25 +1119,20 @@
|
||||
"toggleComment": "Alternar Comentário",
|
||||
"indent": "Indentar",
|
||||
"outdent": "Remover Indentação",
|
||||
"autoComplete": "Auto Completar",
|
||||
"autoComplete": "Autocompletar",
|
||||
"imageLoadError": "Falha ao carregar imagem",
|
||||
"zoomIn": "Aumentar Zoom",
|
||||
"zoomOut": "Diminuir Zoom",
|
||||
"rotate": "Rotacionar",
|
||||
"originalSize": "Tamanho Original",
|
||||
"startTyping": "Comece a digitar...",
|
||||
"unknownSize": "Tamanho desconhecido",
|
||||
"fileIsEmpty": "Arquivo está vazio",
|
||||
"modified": "Modificado",
|
||||
"largeFileWarning": "Aviso de Arquivo Grande",
|
||||
"largeFileWarningDesc": "Este arquivo tem {{size}} de tamanho, o que pode causar problemas de desempenho quando aberto como texto.",
|
||||
"fileNotFoundAndRemoved": "Arquivo \"{{name}}\" não encontrado e foi removido dos arquivos recentes/fixados",
|
||||
"failedToLoadFile": "Falha ao carregar arquivo: {{error}}",
|
||||
"serverErrorOccurred": "Ocorreu um erro no servidor. Por favor, tente novamente mais tarde.",
|
||||
"fileSavedSuccessfully": "Arquivo salvo com sucesso",
|
||||
"autoSaveFailed": "Falha no salvamento automático",
|
||||
"fileAutoSaved": "Arquivo salvo automaticamente",
|
||||
|
||||
"moveFileFailed": "Falha ao mover {{name}}",
|
||||
"moveOperationFailed": "Falha na operação de mover",
|
||||
"canOnlyCompareFiles": "Só é possível comparar dois arquivos",
|
||||
@@ -1019,7 +1147,7 @@
|
||||
"operationCompletedSuccessfully": "{{operation}} {{count}} itens com sucesso",
|
||||
"operationCompleted": "{{operation}} {{count}} itens",
|
||||
"downloadFileSuccess": "Arquivo {{name}} baixado com sucesso",
|
||||
"downloadFileFailed": "Falha no download",
|
||||
"downloadFileFailed": "Falha no baixar",
|
||||
"moveTo": "Mover para {{name}}",
|
||||
"diffCompareWith": "Comparar diferenças com {{name}}",
|
||||
"dragOutsideToDownload": "Arraste para fora da janela para baixar ({{count}} arquivos)",
|
||||
@@ -1038,12 +1166,42 @@
|
||||
"fileComparison": "Comparação de Arquivos: {{file1}} vs {{file2}}",
|
||||
"fileTooLarge": "Arquivo muito grande: {{error}}",
|
||||
"sshConnectionFailed": "Falha na conexão SSH. Por favor, verifique sua conexão com {{name}} ({{ip}}:{{port}})",
|
||||
"loadFileFailed": "Falha ao carregar arquivo: {{error}}"
|
||||
"loadFileFailed": "Falha ao carregar arquivo: {{error}}",
|
||||
"andMoreFiles": "e {{count}} mais...",
|
||||
"archiveExtractedSuccessfully": "{{name}} extraído com sucesso",
|
||||
"archiveName": "Nome do Arquivo",
|
||||
"changePermissions": "Alterar Permissões",
|
||||
"changePermissionsDesc": "Modificar permissões do arquivo para",
|
||||
"compress": "Comprimir",
|
||||
"compressFailed": "Falha na compressão",
|
||||
"compressFile": "Comprimir Arquivo",
|
||||
"compressFiles": "Comprimir Arquivos",
|
||||
"compressFilesDesc": "Comprimir {{count}} itens em um arquivo",
|
||||
"compressingFiles": "Comprimindo {{count}} itens em {{name}}...",
|
||||
"compressionFormat": "Formato de Compressão",
|
||||
"connectedSuccessfully": "Conectado com sucesso",
|
||||
"currentPermissions": "Permissões Atuais",
|
||||
"enterArchiveName": "Inserir nome do arquivo...",
|
||||
"execute": "Executar",
|
||||
"extractArchive": "Extrair Arquivo",
|
||||
"extractFailed": "Extração falhou",
|
||||
"extractingArchive": "Extraindo {{name}}...",
|
||||
"failedToChangePermissions": "Falha ao alterar permissões",
|
||||
"filesCompressedSuccessfully": "{{name}} criado com sucesso",
|
||||
"group": "Grupo",
|
||||
"newPermissions": "Novas Permissões",
|
||||
"others": "Outros",
|
||||
"owner": "Proprietário",
|
||||
"permissionsChangedSuccessfully": "Permissões alteradas com sucesso",
|
||||
"read": "Leitura",
|
||||
"selectedFiles": "Arquivos Selecionados",
|
||||
"totpVerificationFailed": "Verificação TOTP falhou",
|
||||
"write": "Escrita"
|
||||
},
|
||||
"tunnels": {
|
||||
"title": "Túneis SSH",
|
||||
"noSshTunnels": "Sem Túneis SSH",
|
||||
"createFirstTunnelMessage": "Você ainda não criou nenhum túnel SSH. Configure conexões de túnel no Gerenciador de Hosts para começar.",
|
||||
"createFirstTunnelMessage": "Crie seu primeiro túnel SSH para começar. Use o Gerenciador SSH para adicionar hosts com conexões de túnel.",
|
||||
"connected": "Conectado",
|
||||
"disconnected": "Desconectado",
|
||||
"connecting": "Conectando...",
|
||||
@@ -1063,7 +1221,7 @@
|
||||
"port": "Porta",
|
||||
"attempt": "Tentativa {{current}} de {{max}}",
|
||||
"nextRetryIn": "Próxima tentativa em {{seconds}} segundos",
|
||||
"checkDockerLogs": "Verifique seus logs do Docker para ver o motivo do erro, entre no",
|
||||
"checkDockerLogs": "Verifique seus logs do Docker para ver o motivo do erro",
|
||||
"noTunnelConnections": "Nenhuma conexão de túnel configurada",
|
||||
"tunnelConnections": "Conexões de Túnel",
|
||||
"addTunnel": "Adicionar Túnel",
|
||||
@@ -1084,18 +1242,9 @@
|
||||
"local": "Local",
|
||||
"remote": "Remoto",
|
||||
"dynamic": "Dinâmico",
|
||||
"noSshTunnels": "Sem Túneis SSH",
|
||||
"createFirstTunnelMessage": "Crie seu primeiro túnel SSH para começar. Use o Gerenciador SSH para adicionar hosts com conexões de túnel.",
|
||||
"unknownConnectionStatus": "Desconhecido",
|
||||
"connected": "Conectado",
|
||||
"connecting": "Conectando...",
|
||||
"disconnecting": "Desconectando...",
|
||||
"disconnected": "Desconectado",
|
||||
"portMapping": "Porta {{sourcePort}} → {{endpointHost}}:{{endpointPort}}",
|
||||
"disconnect": "Desconectar",
|
||||
"connect": "Conectar",
|
||||
"canceling": "Cancelando...",
|
||||
"endpointHostNotFound": "Host de destino não encontrado",
|
||||
"endpointHostNotFound": "Host de destinenhum não encontrado",
|
||||
"discord": "Discord",
|
||||
"githubIssue": "issue no GitHub",
|
||||
"forHelp": "para ajuda"
|
||||
@@ -1106,8 +1255,8 @@
|
||||
"memory": "Memória",
|
||||
"disk": "Disco",
|
||||
"network": "Rede",
|
||||
"uptime": "Tempo Ativo",
|
||||
"loadAverage": "Carga Média",
|
||||
"uptime": "Tempo de Atividade",
|
||||
"loadAverage": "Média: {{avg1}}, {{avg5}}, {{avg15}}",
|
||||
"processes": "Processos",
|
||||
"connections": "Conexões",
|
||||
"usage": "Uso",
|
||||
@@ -1123,7 +1272,6 @@
|
||||
"cpuCores_one": "{{count}} CPU",
|
||||
"cpuCores_other": "{{count}} CPUs",
|
||||
"naCpus": "N/D CPU(s)",
|
||||
"loadAverage": "Média: {{avg1}}, {{avg5}}, {{avg15}}",
|
||||
"loadAverageNA": "Média: N/D",
|
||||
"cpuUsage": "Uso da CPU",
|
||||
"memoryUsage": "Uso de Memória",
|
||||
@@ -1139,10 +1287,47 @@
|
||||
"serverOffline": "Servidor Offline",
|
||||
"cannotFetchMetrics": "Não é possível buscar métricas do servidor offline",
|
||||
"load": "Carga",
|
||||
"free": "Livre",
|
||||
"available": "Disponível"
|
||||
"addWidget": "Adicionar Widget",
|
||||
"cancelEdit": "Cancelar",
|
||||
"diskUsage": "Uso de Disco",
|
||||
"editLayout": "Editar Layout",
|
||||
"executeQuickAction": "Executar {{name}}",
|
||||
"executingQuickAction": "Executando {{name}}...",
|
||||
"failedToSaveLayout": "Falha ao salvar layout",
|
||||
"from": "de",
|
||||
"hostname": "Hostname",
|
||||
"kernel": "Kernel",
|
||||
"layoutSaved": "Layout salvo com sucesso",
|
||||
"loginStats": "Estatísticas de Login SSH",
|
||||
"networkInterfaces": "Interfaces de Rede",
|
||||
"noInterfacesFound": "Nenhuma interface de rede encontrada",
|
||||
"noProcessesFound": "Nenhum processo encontrado",
|
||||
"noRecentLoginData": "Nenhum dado de login recente",
|
||||
"operatingSystem": "Sistema Operacional",
|
||||
"quickActionError": "Falha ao executar {{name}}",
|
||||
"quickActionFailed": "{{name}} falhou",
|
||||
"quickActionSuccess": "{{name}} concluído com sucesso",
|
||||
"quickActions": "Ações Rápidas",
|
||||
"recentFailedAttempts": "Tentativas Falhas Recentemente",
|
||||
"recentSuccessfulLogins": "Logins Bem-sucedidos Recentemente",
|
||||
"running": "Executando",
|
||||
"saveLayout": "Salvar Layout",
|
||||
"seconds": "segundos",
|
||||
"systemInfo": "Informações do Sistema",
|
||||
"totalLogins": "Total de Logins",
|
||||
"totalProcesses": "Total de Processos",
|
||||
"totalUptime": "Tempo de Atividade Total",
|
||||
"totpRequired": "Autenticação TOTP Obrigatória",
|
||||
"totpUnavailable": "Estatísticas do Servidor indisponíveis para servidores com TOTP ativado",
|
||||
"uniqueIPs": "IPs Únicos",
|
||||
"unsavedChanges": "Alterações não salvas"
|
||||
},
|
||||
"auth": {
|
||||
"tagline": "GERENCIADOR DE TERMINAL SSH",
|
||||
"description": "Gerenciamento de conexão SSH seguro, poderoso e intuitivo",
|
||||
"welcomeBack": "Bem-vindo de volta ao TERMIX",
|
||||
"createAccount": "Crie sua conta TERMIX",
|
||||
"continueExternal": "Continuar com provedor externo",
|
||||
"loginTitle": "Entrar no Termix",
|
||||
"registerTitle": "Criar Conta",
|
||||
"loginButton": "Entrar",
|
||||
@@ -1207,7 +1392,7 @@
|
||||
"enableTwoFactorButton": "Ativar Autenticação de Dois Fatores",
|
||||
"addExtraSecurityLayer": "Adicione uma camada extra de segurança à sua conta",
|
||||
"firstUser": "Primeiro Usuário",
|
||||
"firstUserMessage": "Você é o primeiro usuário e será tornado admin. Você pode ver as configurações de admin no menu suspenso do usuário na barra lateral. Se você acha que isso é um erro, verifique os logs do docker ou crie uma issue no GitHub.",
|
||||
"firstUserMessage": "Você é o primeiro usuário e será tornado administrador. Você pode ver as configurações de administrador no menu suspenso do usuário na barra lateral. Se você acha que isso é um erro, verifique os logs do docker ou crie uma issue no GitHub.",
|
||||
"external": "Externo",
|
||||
"loginWithExternal": "Entrar com Provedor Externo",
|
||||
"loginWithExternalDesc": "Entre usando seu provedor de identidade externo configurado",
|
||||
@@ -1234,7 +1419,18 @@
|
||||
"sshTimeoutDescription": "A tentativa de autenticação expirou. Por favor, tente novamente.",
|
||||
"sshProvideCredentialsDescription": "Por favor, forneça suas credenciais SSH para conectar a este servidor.",
|
||||
"sshPasswordDescription": "Digite a senha para esta conexão SSH.",
|
||||
"sshKeyPasswordDescription": "Se sua chave SSH estiver criptografada, digite a senha aqui."
|
||||
"sshKeyPasswordDescription": "Se sua chave SSH estiver criptografada, digite a senha aqui.",
|
||||
"authenticating": "Autenticando...",
|
||||
"authenticationDisabled": "Autenticação Desativada",
|
||||
"authenticationDisabledDesc": "Todos os métodos de autenticação estão atualmente desativados. Por favor, contate seu administrador.",
|
||||
"desktopApp": "App Desktop",
|
||||
"loadingServer": "Carregando servidor...",
|
||||
"loggingInToDesktopApp": "Entrando no app desktop",
|
||||
"loggingInToDesktopAppViaWeb": "Entrando no app desktop via interface web",
|
||||
"loggingInToMobileApp": "Entrando no app mobile",
|
||||
"mobileApp": "App Mobile",
|
||||
"redirectingToApp": "Redirecionando para o app...",
|
||||
"passwordResetSuccessDesc": "Sua senha foi redefinida com sucesso. Você pode agora entrar com sua nova senha."
|
||||
},
|
||||
"errors": {
|
||||
"notFound": "Página não encontrada",
|
||||
@@ -1265,7 +1461,8 @@
|
||||
"emailExists": "Email já existe",
|
||||
"loadFailed": "Falha ao carregar dados",
|
||||
"saveError": "Falha ao salvar",
|
||||
"sessionExpired": "Sessão expirada - por favor, faça login novamente"
|
||||
"sessionExpired": "Sessão expirada - por favor, faça login novamente",
|
||||
"passwordLoginDisabled": "Login com nome de usuário/senha está atualmente desativado"
|
||||
},
|
||||
"messages": {
|
||||
"saveSuccess": "Salvo com sucesso",
|
||||
@@ -1306,9 +1503,16 @@
|
||||
"local": "Local",
|
||||
"external": "Externo (OIDC)",
|
||||
"selectPreferredLanguage": "Selecione seu idioma preferido para a interface",
|
||||
"fileColorCoding": "Codificação de Cores de Arquivos",
|
||||
"fileColorCodingDesc": "Codificar arquivos por cores por tipo: pastas (vermelho), arquivos (azul), links simbólicos (verde)",
|
||||
"commandAutocomplete": "Autocompletar Comandos",
|
||||
"commandAutocompleteDesc": "Ativar sugestões de autocompletar com a tecla Tab para comandos do terminal baseado no seu histórico",
|
||||
"defaultSnippetFoldersCollapsed": "Recolher Pastas de Snippets por Padrão",
|
||||
"defaultSnippetFoldersCollapsedDesc": "Quando ativado, todas as pastas de snippets serão recolhidas ao abrir a aba de snippets",
|
||||
"currentPassword": "Senha Atual",
|
||||
"passwordChangedSuccess": "Senha alterada com sucesso! Por favor, faça login novamente.",
|
||||
"failedToChangePassword": "Falha ao alterar a senha. Por favor, verifique sua senha atual e tente novamente."
|
||||
"failedToChangePassword": "Falha ao alterar a senha. Por favor, verifique sua senha atual e tente novamente.",
|
||||
"externalAndLocal": "Autenticação Dupla"
|
||||
},
|
||||
"user": {
|
||||
"failedToLoadVersionInfo": "Falha ao carregar informações da versão"
|
||||
@@ -1338,10 +1542,10 @@
|
||||
"redirectUrl": "https://seu-provedor.com/application/o/termix/",
|
||||
"tokenUrl": "https://seu-provedor.com/application/o/token/",
|
||||
"userIdField": "sub",
|
||||
"usernameField": "name",
|
||||
"usernameField": "nome",
|
||||
"scopes": "openid email profile",
|
||||
"userinfoUrl": "https://your-provider.com/application/o/userinfo/",
|
||||
"enterUsername": "Digite o nome de usuário para tornar admin",
|
||||
"userinfoUrl": "https://seu-provider.com/application/o/userinfo/",
|
||||
"enterUsername": "Digite o nome de usuário para tornar administrador",
|
||||
"searchHosts": "Procurar hosts por nome, usuário, IP, pasta, tags...",
|
||||
"enterPassword": "Digite sua senha",
|
||||
"totpCode": "Código TOTP de 6 dígitos",
|
||||
@@ -1359,9 +1563,9 @@
|
||||
"noFolder": "Sem Pasta",
|
||||
"passwordRequired": "Senha é obrigatória",
|
||||
"failedToDeleteAccount": "Falha ao excluir conta",
|
||||
"failedToMakeUserAdmin": "Falha ao tornar usuário admin",
|
||||
"userIsNowAdmin": "Usuário {{username}} agora é um admin",
|
||||
"removeAdminConfirm": "Tem certeza que deseja remover o status de admin de {{username}}?",
|
||||
"failedToMakeUserAdmin": "Falha ao tornar usuário administrador",
|
||||
"userIsNowAdmin": "Usuário {{username}} agora é um administrador",
|
||||
"removeAdminConfirm": "Tem certeza que deseja remover o status de administrador de {{username}}?",
|
||||
"deleteUserConfirm": "Tem certeza que deseja excluir o usuário {{username}}? Esta ação não pode ser desfeita.",
|
||||
"deleteAccount": "Excluir Conta",
|
||||
"closeDeleteAccount": "Fechar Exclusão de Conta",
|
||||
@@ -1369,7 +1573,7 @@
|
||||
"deleteAccountWarningDetails": "Excluir sua conta removerá todos os seus dados, incluindo hosts SSH, configurações e preferências. Esta ação é irreversível.",
|
||||
"deleteAccountWarningShort": "Esta ação é irreversível e excluirá permanentemente sua conta.",
|
||||
"cannotDeleteAccount": "Não é Possível Excluir Conta",
|
||||
"lastAdminWarning": "Você é o último usuário administrador. Você não pode excluir sua conta pois isso deixaria o sistema sem administradores. Por favor, torne outro usuário administrador primeiro, ou contate o suporte do sistema.",
|
||||
"lastAdminWarning": "Você é o último administrador. Você não pode excluir sua conta pois isso deixaria o sistema sem administradores. Por favor, torne outro usuário administrador primeiro, ou contate o suporte do sistema.",
|
||||
"confirmPassword": "Confirmar Senha",
|
||||
"deleting": "Excluindo...",
|
||||
"cancel": "Cancelar"
|
||||
@@ -1392,7 +1596,6 @@
|
||||
"deleteItem": "Excluir Item",
|
||||
"createNewFile": "Criar Novo Arquivo",
|
||||
"createNewFolder": "Criar Nova Pasta",
|
||||
"deleteItem": "Excluir Item",
|
||||
"renameItem": "Renomear Item",
|
||||
"clickToSelectFile": "Clique para selecionar um arquivo",
|
||||
"noSshHosts": "Sem Hosts SSH",
|
||||
@@ -1488,5 +1691,95 @@
|
||||
"mobileAppInProgressDesc": "Estamos trabalhando em um aplicativo móvel dedicado para proporcionar uma melhor experiência em dispositivos móveis.",
|
||||
"viewMobileAppDocs": "Instalar Aplicativo Móvel",
|
||||
"mobileAppDocumentation": "Documentação do Aplicativo Móvel"
|
||||
},
|
||||
"commandPalette": {
|
||||
"addCredential": "Adicionar Credencial",
|
||||
"addHost": "Adicionar Host",
|
||||
"adminSettings": "Configurações de Administrador",
|
||||
"close": "Fechar",
|
||||
"discord": "Discord",
|
||||
"donate": "Doar",
|
||||
"edit": "Editar",
|
||||
"github": "GitHub",
|
||||
"hostManager": "Gerenciador de Hosts",
|
||||
"hosts": "Hosts",
|
||||
"links": "Links",
|
||||
"navigation": "Navegação",
|
||||
"openFileManager": "Abrir Gerenciador de Arquivos",
|
||||
"openServerDetails": "Abrir Detalhes do Servidor",
|
||||
"press": "Pressione",
|
||||
"recentActivity": "Atividade Recente",
|
||||
"searchPlaceholder": "Pesquisar por hosts ou ações rápidas...",
|
||||
"support": "Suporte",
|
||||
"toToggle": "para alternar",
|
||||
"updateLog": "Log de Atualizações",
|
||||
"userProfile": "Perfil do Usuário"
|
||||
},
|
||||
"dashboard": {
|
||||
"addCredential": "Adicionar Credencial",
|
||||
"addHost": "Adicionar Host",
|
||||
"adminSettings": "Configurações de Administrador",
|
||||
"cpu": "CPU",
|
||||
"database": "Banco de Dados",
|
||||
"discord": "Discord",
|
||||
"donate": "Doar",
|
||||
"error": "Erro",
|
||||
"github": "GitHub",
|
||||
"healthy": "Saudável",
|
||||
"loadingRecentActivity": "Carregando atividade recente...",
|
||||
"loadingServerStats": "Carregando estatísticas do servidor...",
|
||||
"noRecentActivity": "Nenhuma atividade recente",
|
||||
"noServerData": "Nenhum dado do servidor disponível",
|
||||
"notAvailable": "N/D",
|
||||
"quickActions": "Ações Rápidas",
|
||||
"ram": "RAM",
|
||||
"recentActivity": "Atividade Recente",
|
||||
"reset": "Redefinir",
|
||||
"serverOverview": "Visão Geral do Servidor",
|
||||
"serverStats": "Estatísticas do Servidor",
|
||||
"support": "Suporte",
|
||||
"title": "Painel",
|
||||
"totalCredentials": "Total de Credenciais",
|
||||
"totalServers": "Total de Servidores",
|
||||
"totalTunnels": "Total de Túneis",
|
||||
"upToDate": "Atualizado",
|
||||
"updateAvailable": "Atualização Disponível",
|
||||
"uptime": "Tempo de Atividade",
|
||||
"userProfile": "Perfil do Usuário",
|
||||
"version": "Versão"
|
||||
},
|
||||
"snippets": {
|
||||
"content": "Comando",
|
||||
"contentPlaceholder": "ex: sudo systemctl restart nginx",
|
||||
"contentRequired": "Comando é obrigatório",
|
||||
"copySuccess": "Copiado \"{{name}}\" para área de transferência",
|
||||
"copyTooltip": "Copiar snippet para área de transferência",
|
||||
"create": "Criar Snippet",
|
||||
"createDescription": "Criar um novo snippet de comando para execução rápida",
|
||||
"createFailed": "Falha ao criar snippet",
|
||||
"createSuccess": "Snippet criado com sucesso",
|
||||
"deleteConfirmDescription": "Tem certeza de que deseja excluir \"{{name}}\"?",
|
||||
"deleteConfirmTitle": "Excluir Snippet",
|
||||
"deleteFailed": "Falha ao excluir snippet",
|
||||
"deleteSuccess": "Snippet excluído com sucesso",
|
||||
"deleteTooltip": "Excluir este snippet",
|
||||
"description": "Descrição",
|
||||
"descriptionPlaceholder": "Descrição opcional",
|
||||
"edit": "Editar Snippet",
|
||||
"editDescription": "Editar este snippet de comando",
|
||||
"editTooltip": "Editar este snippet",
|
||||
"empty": "Nenhum snippet ainda",
|
||||
"emptyHint": "Crie um snippet para salvar comandos comumente usados",
|
||||
"executeSuccess": "Executando: {{name}}",
|
||||
"failedToFetch": "Falha ao buscar snippets",
|
||||
"name": "Nome",
|
||||
"namePlaceholder": "ex: Reiniciar Nginx",
|
||||
"nameRequired": "Nome é obrigatório",
|
||||
"new": "Novo Snippet",
|
||||
"run": "Executar",
|
||||
"runTooltip": "Executar este snippet no terminal",
|
||||
"title": "Snippets",
|
||||
"updateFailed": "Falha ao atualizar snippet",
|
||||
"updateSuccess": "Snippet atualizado com sucesso"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,6 @@
|
||||
"keyTypeRSA": "RSA",
|
||||
"keyTypeECDSA": "ECDSA",
|
||||
"keyTypeEd25519": "Ed25519",
|
||||
"updateCredential": "Обновить учетные данные",
|
||||
"basicInfo": "Основная информация",
|
||||
"authentication": "Аутентификация",
|
||||
"organization": "Организация",
|
||||
@@ -118,7 +117,6 @@
|
||||
"credentialSecuredDescription": "Все конфиденциальные данные зашифрованы с помощью AES-256",
|
||||
"passwordAuthentication": "Аутентификация по паролю",
|
||||
"keyAuthentication": "Аутентификация по ключу",
|
||||
"keyType": "Тип ключа",
|
||||
"securityReminder": "Напоминание о безопасности",
|
||||
"securityReminderText": "Никогда не передавайте ваши учетные данные. Все данные зашифрованы при хранении.",
|
||||
"hostsUsingCredential": "Хосты, использующие эти учетные данные",
|
||||
@@ -166,7 +164,8 @@
|
||||
"generateKeyPairNote": "Сгенерировать новую пару SSH-ключей напрямую. Это заменит любые существующие ключи в форме.",
|
||||
"invalidKey": "Неверный ключ",
|
||||
"detectionError": "Ошибка определения",
|
||||
"unknown": "Неизвестно"
|
||||
"unknown": "Неизвестно",
|
||||
"credentialId": "Учётные данные ID"
|
||||
},
|
||||
"dragIndicator": {
|
||||
"error": "Ошибка: {{error}}",
|
||||
@@ -225,6 +224,20 @@
|
||||
"editTooltip": "Редактировать этот сниппет",
|
||||
"deleteTooltip": "Удалить этот сниппет"
|
||||
},
|
||||
"commandHistory": {
|
||||
"title": "История",
|
||||
"searchPlaceholder": "Поиск команд...",
|
||||
"noTerminal": "Нет активного терминала",
|
||||
"noTerminalHint": "Откройте терминал, чтобы увидеть историю команд.",
|
||||
"empty": "История команд пока пуста",
|
||||
"emptyHint": "Выполните команды в активном терминале, чтобы создать историю.",
|
||||
"noResults": "Команды не найдены",
|
||||
"noResultsHint": "Нет команд, соответствующих \"{{query}}\"",
|
||||
"deleteSuccess": "Команда удалена из истории",
|
||||
"deleteFailed": "Не удалось удалить команду.",
|
||||
"deleteTooltip": "Удалить команду",
|
||||
"tabHint": "Используйте Tab в Терминале для автозаполнения из истории команд"
|
||||
},
|
||||
"homepage": {
|
||||
"loggedInTitle": "Вы вошли в систему!",
|
||||
"loggedInMessage": "Вы вошли в систему! Используйте боковую панель для доступа ко всем доступным инструментам. Чтобы начать, создайте SSH-хост в разделе SSH-менеджера. После создания вы можете подключиться к этому хосту, используя другие приложения на боковой панели.",
|
||||
@@ -247,7 +260,11 @@
|
||||
"saveError": "Ошибка сохранения конфигурации",
|
||||
"saving": "Сохранение...",
|
||||
"saveConfig": "Сохранить конфигурацию",
|
||||
"helpText": "Введите URL, где работает ваш сервер Termix (например, http://localhost:30001 или https://your-server.com)"
|
||||
"helpText": "Введите URL, где работает ваш сервер Termix (например, http://localhost:30001 или https://your-server.com)",
|
||||
"changeServer": "Сменить сервер",
|
||||
"mustIncludeProtocol": "URL сервера должен начинаться с http:// или https://",
|
||||
"notValidatedWarning": "URL не проверен - убедитесь, что он правильный",
|
||||
"warning": "Предупреждение"
|
||||
},
|
||||
"versionCheck": {
|
||||
"error": "Ошибка проверки версии",
|
||||
@@ -280,7 +297,7 @@
|
||||
"warning": "Предупреждение",
|
||||
"info": "Информация",
|
||||
"success": "Успех",
|
||||
"loading": "Загрузка",
|
||||
"loading": "Загрузка...",
|
||||
"required": "Обязательно",
|
||||
"optional": "Опционально",
|
||||
"clear": "Очистить",
|
||||
@@ -294,7 +311,6 @@
|
||||
"updateAvailable": "Доступно обновление",
|
||||
"sshPath": "SSH-путь",
|
||||
"localPath": "Локальный путь",
|
||||
"loading": "Загрузка...",
|
||||
"noAuthCredentials": "Нет учетных данных аутентификации для этого SSH-хоста",
|
||||
"noReleases": "Нет выпусков",
|
||||
"updatesAndReleases": "Обновления и выпуски",
|
||||
@@ -309,17 +325,13 @@
|
||||
"resetPassword": "Сбросить пароль",
|
||||
"resetCode": "Код сброса",
|
||||
"newPassword": "Новый пароль",
|
||||
"sshPath": "SSH-путь",
|
||||
"localPath": "Локальный путь",
|
||||
"folder": "Папка",
|
||||
"file": "Файл",
|
||||
"renamedSuccessfully": "успешно переименован",
|
||||
"deletedSuccessfully": "успешно удален",
|
||||
"noAuthCredentials": "Нет учетных данных аутентификации для этого SSH-хоста",
|
||||
"noTunnelConnections": "Нет настроенных туннельных подключений",
|
||||
"sshTools": "SSH-инструменты",
|
||||
"english": "Английский",
|
||||
"russia": "Русский",
|
||||
"chinese": "Китайский",
|
||||
"german": "Немецкий",
|
||||
"cancel": "Отмена",
|
||||
@@ -328,36 +340,27 @@
|
||||
"login": "Войти",
|
||||
"logout": "Выйти",
|
||||
"register": "Зарегистрироваться",
|
||||
"username": "Имя пользователя",
|
||||
"password": "Пароль",
|
||||
"version": "Версия",
|
||||
"confirmPassword": "Подтвердите пароль",
|
||||
"back": "Назад",
|
||||
"email": "Email",
|
||||
"submit": "Отправить",
|
||||
"cancel": "Отмена",
|
||||
"change": "Изменить",
|
||||
"save": "Сохранить",
|
||||
"delete": "Удалить",
|
||||
"edit": "Редактировать",
|
||||
"add": "Добавить",
|
||||
"search": "Поиск",
|
||||
"loading": "Загрузка...",
|
||||
"error": "Ошибка",
|
||||
"success": "Успех",
|
||||
"warning": "Предупреждение",
|
||||
"info": "Информация",
|
||||
"confirm": "Подтвердить",
|
||||
"yes": "Да",
|
||||
"no": "Нет",
|
||||
"ok": "OK",
|
||||
"close": "Закрыть",
|
||||
"enabled": "Включено",
|
||||
"disabled": "Отключено",
|
||||
"important": "Важно",
|
||||
"notEnabled": "Не включено",
|
||||
"settingUp": "Настройка...",
|
||||
"back": "Назад",
|
||||
"next": "Далее",
|
||||
"previous": "Назад",
|
||||
"refresh": "Обновить",
|
||||
@@ -381,7 +384,10 @@
|
||||
"documentation": "Документация",
|
||||
"retry": "Повторить",
|
||||
"checking": "Проверка...",
|
||||
"checkingDatabase": "Проверка подключения к базе данных..."
|
||||
"checkingDatabase": "Проверка подключения к базе данных...",
|
||||
"connect": "Подключить",
|
||||
"connecting": "Подключение...",
|
||||
"saving": "Сохранение..."
|
||||
},
|
||||
"nav": {
|
||||
"home": "Главная",
|
||||
@@ -410,7 +416,7 @@
|
||||
"userManagement": "Управление пользователями",
|
||||
"makeAdmin": "Сделать администратором",
|
||||
"removeAdmin": "Убрать администратора",
|
||||
"deleteUser": "Удалить пользователя",
|
||||
"deleteUser": "Удалить пользователя {{username}}? Это нельзя отменить.",
|
||||
"allowRegistration": "Разрешить регистрацию",
|
||||
"oidcSettings": "Настройки OIDC",
|
||||
"clientId": "Client ID",
|
||||
@@ -464,10 +470,9 @@
|
||||
"removeAdminStatus": "Убрать статус администратора у {{username}}?",
|
||||
"adminStatusRemoved": "Статус администратора убран у {{username}}",
|
||||
"failedToRemoveAdminStatus": "Не удалось убрать статус администратора",
|
||||
"deleteUser": "Удалить пользователя {{username}}? Это нельзя отменить.",
|
||||
"userDeletedSuccessfully": "Пользователь {{username}} успешно удален",
|
||||
"failedToDeleteUser": "Не удалось удалить пользователя",
|
||||
"overrideUserInfoUrl": "Переопределить User Info URL (не требуется)",
|
||||
"overrideUserInfoUrl": "Переопределить Пользователь Info URL (не требуется)",
|
||||
"databaseSecurity": "Безопасность базы данных",
|
||||
"encryptionStatus": "Статус шифрования",
|
||||
"encryptionEnabled": "Шифрование включено",
|
||||
@@ -517,7 +522,6 @@
|
||||
"verificationCompleted": "Проверка совместимости завершена - данные не изменялись",
|
||||
"verificationInProgress": "Проверка завершена",
|
||||
"dataMigrationCompleted": "Миграция данных успешно завершена!",
|
||||
"migrationCompleted": "Миграция завершена",
|
||||
"verificationFailed": "Проверка совместимости не удалась",
|
||||
"migrationFailed": "Миграция не удалась",
|
||||
"runningVerification": "Выполняется проверка совместимости...",
|
||||
@@ -595,7 +599,33 @@
|
||||
"requiresPasswordLogin": "Требуется включенный вход по паролю",
|
||||
"passwordLoginDisabledWarning": "Вход по паролю отключен. Убедитесь, что OIDC правильно настроен, иначе вы не сможете войти в Termix.",
|
||||
"oidcRequiredWarning": "КРИТИЧЕСКИ: Вход по паролю отключен. Если вы сбросите или неправильно настроите OIDC, вы потеряете весь доступ к Termix и заблокируете свой экземпляр. Продолжайте только если вы абсолютно уверены.",
|
||||
"confirmDisableOIDCWarning": "ПРЕДУПРЕЖДЕНИЕ: Вы собираетесь отключить OIDC, пока вход по паролю также отключен. Это заблокирует ваш экземпляр Termix, и вы потеряете весь доступ. Вы абсолютно уверены, что хотите продолжить?"
|
||||
"confirmDisableOIDCWarning": "ПРЕДУПРЕЖДЕНИЕ: Вы собираетесь отключить OIDC, пока вход по паролю также отключен. Это заблокирует ваш экземпляр Termix, и вы потеряете весь доступ. Вы абсолютно уверены, что хотите продолжить?",
|
||||
"accountsLinkedSuccessfully": "OIDC пользователь {{oidcUsername}} связан с {{targetUsername}}",
|
||||
"confirmRevokeAllSessions": "Вы уверены, что хотите отозвать все сессии для этого пользователя?",
|
||||
"confirmRevokeSession": "Вы уверены, что хотите отозвать эту сессию?",
|
||||
"failedToFetchSessions": "Не удалось загрузить сессии",
|
||||
"failedToLinkAccounts": "Не удалось связать аккаунты",
|
||||
"failedToRevokeSession": "Не удалось отозвать сессию",
|
||||
"failedToRevokeSessions": "Не удалось отозвать сессии",
|
||||
"failedToUnlinkOIDC": "Не удалось отвязать OIDC",
|
||||
"linkAccountsButton": "Связать аккаунты",
|
||||
"linkOIDCActionAddCapability": "Добавить возможность входа через OIDC к целевому аккаунту с паролем",
|
||||
"linkOIDCActionDeleteUser": "Удалить аккаунт пользователя OIDC и все его данные",
|
||||
"linkOIDCActionDualAuth": "Разрешить аккаунту с паролем вход как по паролю, так и через OIDC",
|
||||
"linkOIDCDialogDescription": "Связать {{username}} (пользователь OIDC) с существующим аккаунтом с паролем. Это включит двойную аутентификацию для аккаунта с паролем.",
|
||||
"linkOIDCDialogTitle": "Связать аккаунт OIDC с аккаунтом с паролем",
|
||||
"linkOIDCWarningTitle": "Предупреждение: Данные пользователя OIDC будут удалены",
|
||||
"linkTargetUsernameLabel": "Имя пользователя целевого аккаунта с паролем",
|
||||
"linkTargetUsernamePlaceholder": "Введите имя пользователя аккаунта с паролем",
|
||||
"linkTargetUsernameRequired": "Целевое имя пользователя обязательно",
|
||||
"linkToPasswordAccount": "Связать с аккаунтом с паролем",
|
||||
"linkingAccounts": "Связывание...",
|
||||
"sessionRevokedSuccessfully": "Сессия успешно отозвана",
|
||||
"sessionsRevokedSuccessfully": "Сессии успешно отозваны",
|
||||
"unlinkOIDCDescription": "Удалить аутентификацию OIDC для {{username}}? После этого пользователь сможет войти только с помощью имени пользователя/пароля.",
|
||||
"unlinkOIDCSuccess": "OIDC отвязан от {{username}}",
|
||||
"unlinkOIDCTitle": "Отвязать аутентификацию OIDC",
|
||||
"failedToUpdatePasswordLoginStatus": "Не удалось обновить статус входа по паролю"
|
||||
},
|
||||
"hosts": {
|
||||
"title": "Менеджер хостов",
|
||||
@@ -766,9 +796,103 @@
|
||||
"statusMonitoring": "Статус",
|
||||
"metricsMonitoring": "Метрики",
|
||||
"terminalCustomizationNotice": "Примечание: Настройки терминала работают только на рабочем столе (веб-сайт и Electron-приложение). Мобильные приложения и мобильный веб-сайт используют системные настройки терминала по умолчанию.",
|
||||
"terminalCustomization": "Настройка терминала",
|
||||
"appearance": "Внешний вид",
|
||||
"behavior": "Поведение",
|
||||
"advanced": "Расширенные",
|
||||
"themePreview": "Предпросмотр темы",
|
||||
"theme": "Тема",
|
||||
"selectTheme": "Выбрать тему",
|
||||
"chooseColorTheme": "Выберите цветовую тему для терминала",
|
||||
"fontFamily": "Семейство шрифтов",
|
||||
"selectFont": "Выбрать шрифт",
|
||||
"selectFontDesc": "Выберите шрифт для использования в терминале",
|
||||
"fontSize": "Размер шрифта",
|
||||
"fontSizeValue": "Размер шрифта: {{value}}px",
|
||||
"adjustFontSize": "Настроить размер шрифта терминала",
|
||||
"letterSpacing": "Межбуквенный интервал",
|
||||
"letterSpacingValue": "Межбуквенный интервал: {{value}}px",
|
||||
"adjustLetterSpacing": "Настроить расстояние между символами",
|
||||
"lineHeight": "Высота строки",
|
||||
"lineHeightValue": "Высота строки: {{value}}",
|
||||
"adjustLineHeight": "Настроить расстояние между строками",
|
||||
"cursorStyle": "Стиль курсора",
|
||||
"selectCursorStyle": "Выбрать стиль курсора",
|
||||
"cursorStyleBlock": "Блок",
|
||||
"cursorStyleUnderline": "Подчеркивание",
|
||||
"cursorStyleBar": "Полоса",
|
||||
"chooseCursorAppearance": "Выбрать внешний вид курсора",
|
||||
"cursorBlink": "Мигание курсора",
|
||||
"enableCursorBlink": "Включить анимацию мигания курсора",
|
||||
"scrollbackBuffer": "Буфер прокрутки",
|
||||
"scrollbackBufferValue": "Буфер прокрутки: {{value}} строк",
|
||||
"scrollbackBufferDesc": "Количество строк для хранения в истории прокрутки",
|
||||
"bellStyle": "Стиль звонка",
|
||||
"selectBellStyle": "Выбрать стиль звонка",
|
||||
"bellStyleNone": "Нет",
|
||||
"bellStyleSound": "Звук",
|
||||
"bellStyleVisual": "Визуальный",
|
||||
"bellStyleBoth": "Оба",
|
||||
"bellStyleDesc": "Как обрабатывать звонок терминала (символ BEL, \\x07). Программы вызывают его при завершении задач, возникновении ошибок или для уведомлений. \"Звук\" воспроизводит звуковой сигнал, \"Визуальный\" кратковременно мигает экран, \"Оба\" делает и то, и другое, \"Нет\" отключает звуковые оповещения.",
|
||||
"rightClickSelectsWord": "Правый клик выбирает слово",
|
||||
"rightClickSelectsWordDesc": "Правый клик выбирает слово под курсором",
|
||||
"fastScrollModifier": "Модификатор быстрой прокрутки",
|
||||
"selectModifier": "Выбрать модификатор",
|
||||
"modifierAlt": "Alt",
|
||||
"modifierCtrl": "Ctrl",
|
||||
"modifierShift": "Shift",
|
||||
"fastScrollModifierDesc": "Клавиша-модификатор для быстрой прокрутки",
|
||||
"fastScrollSensitivity": "Чувствительность быстрой прокрутки",
|
||||
"fastScrollSensitivityValue": "Чувствительность быстрой прокрутки: {{value}}",
|
||||
"fastScrollSensitivityDesc": "Множитель скорости прокрутки при удержании модификатора",
|
||||
"minimumContrastRatio": "Минимальная контрастность",
|
||||
"minimumContrastRatioValue": "Минимальная контрастность: {{value}}",
|
||||
"minimumContrastRatioDesc": "Автоматически настраивать цвета для лучшей читаемости",
|
||||
"sshAgentForwarding": "Переадресация SSH-агента",
|
||||
"sshAgentForwardingDesc": "Переадресовать агент SSH-аутентификации на удаленный хост",
|
||||
"backspaceMode": "Режим Назадspace",
|
||||
"selectBackspaceMode": "Выбрать режим Назадspace",
|
||||
"backspaceModeNormal": "Обычный (DEL)",
|
||||
"backspaceModeControlH": "Control-H (^H)",
|
||||
"backspaceModeDesc": "Поведение клавиши Назадspace для совместимости",
|
||||
"startupSnippet": "Сниппет запуска",
|
||||
"selectSnippet": "Выбрать сниппет",
|
||||
"searchSnippets": "Поиск сниппетов...",
|
||||
"snippetNone": "Нет",
|
||||
"noneAuthTitle": "Интерактивная аутентификация по клавиатуре",
|
||||
"noneAuthDescription": "Этот метод аутентификации будет использовать интерактивную аутентификацию по клавиатуре при подключении к SSH-серверу.",
|
||||
"noneAuthDetails": "Интерактивная аутентификация по клавиатуре позволяет серверу запрашивать у вас учетные данные во время подключения. Это полезно для серверов, которые требуют многофакторную аутентификацию или динамический ввод пароля."
|
||||
"noneAuthDetails": "Интерактивная аутентификация по клавиатуре позволяет серверу запрашивать у вас учетные данные во время подключения. Это полезно для серверов, которые требуют многофакторную аутентификацию или динамический ввод пароля.",
|
||||
"forceKeyboardInteractive": "Принудительная клавиатурная аутентификация",
|
||||
"forceKeyboardInteractiveDesc": "Принудительно использует интерактивную аутентификацию по клавиатуре. Часто требуется для серверов с двухфакторной аутентификацией (TOTP/2FA).",
|
||||
"overrideCredentialUsername": "Переопределить имя пользователя учетных данных",
|
||||
"overrideCredentialUsernameDesc": "Используйте другое имя пользователя, отличное от того, что хранится в учетных данных. Это позволяет использовать одни и те же учетные данные с разными именами пользователей.",
|
||||
"jumpHosts": "Промежуточные хосты",
|
||||
"jumpHostsDescription": "Промежуточные хосты (также известные как бастионы) позволяют подключаться к целевому серверу через один или несколько промежуточных серверов. Это полезно для доступа к серверам за брандмауэрами или в частных сетях.",
|
||||
"jumpHostChain": "Цепочка промежуточных хостов",
|
||||
"addJumpHost": "Добавить промежуточный хост",
|
||||
"selectServer": "Выбрать сервер",
|
||||
"searchServers": "Поиск серверов...",
|
||||
"noServerFound": "Сервер не найден",
|
||||
"jumpHostsOrder": "Подключения будут выполнены в порядке: Промежуточный хост 1 → Промежуточный хост 2 → ... → Целевой сервер",
|
||||
"advancedAuthSettings": "Расширенные настройки аутентификации",
|
||||
"addQuickAction": "Добавить Quick Action",
|
||||
"allHostsInFolderDeleted": "{{count}} хостов успешно удалены из папки \"{{folder}}\"",
|
||||
"confirmDeleteAllHostsInFolder": "Вы уверены, что хотите удалить все {{count}} хостов в папке \"{{folder}}\"? Это действие нельзя отменить.",
|
||||
"deleteAllHostsInFolder": "Удалить все хосты в папке",
|
||||
"editFolderAppearance": "Редактировать вид папки",
|
||||
"editFolderAppearanceDesc": "Настроить цвет и иконку для папки",
|
||||
"failedToDeleteHostsInFolder": "Не удалось удалить хосты в папке",
|
||||
"failedToUpdateFolderAppearance": "Не удалось обновить вид папки",
|
||||
"folderAppearanceUpdated": "Вид папки успешно обновлен",
|
||||
"folderColor": "Цвет папки",
|
||||
"folderIcon": "Иконка папки",
|
||||
"noSnippetFound": "Сниппет не найден",
|
||||
"preview": "Предпросмотр",
|
||||
"quickActionName": "Название действия",
|
||||
"quickActions": "Быстрые действия",
|
||||
"quickActionsDescription": "Быстрые действия позволяют создавать пользовательские кнопки, выполняющие SSH-сниппеты на этом сервере. Эти кнопки появятся в верхней части страницы статистики сервера для быстрого доступа.",
|
||||
"quickActionsList": "Список быстрых действий",
|
||||
"quickActionsOrder": "Кнопки быстрых действий появятся в указанном выше порядке на странице статистики сервера"
|
||||
},
|
||||
"terminal": {
|
||||
"title": "Терминал",
|
||||
@@ -806,7 +930,11 @@
|
||||
"totpRequired": "Требуется двухфакторная аутентификация",
|
||||
"totpCodeLabel": "Код проверки",
|
||||
"totpPlaceholder": "000000",
|
||||
"totpVerify": "Проверить"
|
||||
"totpVerify": "Проверить",
|
||||
"sudoPasswordPopupTitle": "Вставить пароль?",
|
||||
"sudoPasswordPopupHint": "Нажмите Enter для вставки, Esc для отмены",
|
||||
"sudoPasswordPopupConfirm": "Вставить",
|
||||
"sudoPasswordPopupDismiss": "Отмена"
|
||||
},
|
||||
"fileManager": {
|
||||
"title": "Файловый менеджер",
|
||||
@@ -888,9 +1016,10 @@
|
||||
"copy": "Копировать",
|
||||
"cut": "Вырезать",
|
||||
"paste": "Вставить",
|
||||
"copyPath": "Копировать путь",
|
||||
"copyPaths": "Копировать пути",
|
||||
"delete": "Удалить",
|
||||
"properties": "Свойства",
|
||||
"preview": "Просмотр",
|
||||
"refresh": "Обновить",
|
||||
"downloadFiles": "Скачать {{count}} файлов в браузер",
|
||||
"copyFiles": "Копировать {{count}} элементов",
|
||||
@@ -898,22 +1027,18 @@
|
||||
"deleteFiles": "Удалить {{count}} элементов",
|
||||
"filesCopiedToClipboard": "{{count}} элементов скопировано в буфер обмена",
|
||||
"filesCutToClipboard": "{{count}} элементов вырезано в буфер обмена",
|
||||
"pathCopiedToClipboard": "Путь скопирован в буфер обмена",
|
||||
"pathsCopiedToClipboard": "{{count}} путей скопировано в буфер обмена",
|
||||
"failedToCopyPath": "Не удалось скопировать путь в буфер обмена",
|
||||
"movedItems": "Перемещено {{count}} элементов",
|
||||
"failedToDeleteItem": "Не удалось удалить элемент",
|
||||
"itemRenamedSuccessfully": "{{type}} успешно переименован",
|
||||
"failedToRenameItem": "Не удалось переименовать элемент",
|
||||
"upload": "Загрузить",
|
||||
"download": "Скачать",
|
||||
"newFile": "Новый файл",
|
||||
"newFolder": "Новая папка",
|
||||
"rename": "Переименовать",
|
||||
"delete": "Удалить",
|
||||
"permissions": "Права доступа",
|
||||
"size": "Размер",
|
||||
"modified": "Изменен",
|
||||
"path": "Путь",
|
||||
"fileName": "Имя файла",
|
||||
"folderName": "Имя папки",
|
||||
"confirmDelete": "Вы уверены, что хотите удалить {{name}}?",
|
||||
"uploadSuccess": "Файл успешно загружен",
|
||||
"uploadFailed": "Не удалось загрузить файл",
|
||||
@@ -933,10 +1058,7 @@
|
||||
"fileSavedSuccessfully": "Файл успешно сохранен",
|
||||
"saveTimeout": "Операция сохранения превысила время ожидания. Файл мог быть успешно сохранен, но операция заняла слишком много времени для завершения. Проверьте логи Docker для подтверждения.",
|
||||
"failedToSaveFile": "Не удалось сохранить файл",
|
||||
"folder": "Папка",
|
||||
"file": "Файл",
|
||||
"deletedSuccessfully": "успешно удален",
|
||||
"failedToDeleteItem": "Не удалось удалить элемент",
|
||||
"connectToServer": "Подключиться к серверу",
|
||||
"selectServerToEdit": "Выберите сервер на боковой панели, чтобы начать редактирование файлов",
|
||||
"fileOperations": "Файловые операции",
|
||||
@@ -993,10 +1115,8 @@
|
||||
"unpinFile": "Открепить файл",
|
||||
"removeShortcut": "Удалить ярлык",
|
||||
"saveFilesToSystem": "Сохранить {{count}} файлов как...",
|
||||
"saveToSystem": "Сохранить как...",
|
||||
"pinFile": "Закрепить файл",
|
||||
"addToShortcuts": "Добавить в ярлыки",
|
||||
"selectLocationToSave": "Выберите место для сохранения",
|
||||
"downloadToDefaultLocation": "Скачать в место по умолчанию",
|
||||
"pasteFailed": "Вставка не удалась",
|
||||
"noUndoableActions": "Нет действий для отмены",
|
||||
@@ -1014,7 +1134,6 @@
|
||||
"editPath": "Редактировать путь",
|
||||
"confirm": "Подтвердить",
|
||||
"cancel": "Отмена",
|
||||
"folderName": "Имя папки",
|
||||
"find": "Найти...",
|
||||
"replaceWith": "Заменить на...",
|
||||
"replace": "Заменить",
|
||||
@@ -1040,23 +1159,18 @@
|
||||
"outdent": "Уменьшить отступ",
|
||||
"autoComplete": "Автозавершение",
|
||||
"imageLoadError": "Не удалось загрузить изображение",
|
||||
"zoomIn": "Увеличить",
|
||||
"zoomOut": "Уменьшить",
|
||||
"rotate": "Повернуть",
|
||||
"originalSize": "Оригинальный размер",
|
||||
"startTyping": "Начните печатать...",
|
||||
"unknownSize": "Неизвестный размер",
|
||||
"fileIsEmpty": "Файл пуст",
|
||||
"modified": "Изменен",
|
||||
"largeFileWarning": "Предупреждение о большом файле",
|
||||
"largeFileWarningDesc": "Этот файл имеет размер {{size}}, что может вызвать проблемы с производительностью при открытии как текста.",
|
||||
"fileNotFoundAndRemoved": "Файл \"{{name}}\" не найден и был удален из недавних/закрепленных файлов",
|
||||
"failedToLoadFile": "Не удалось загрузить файл: {{error}}",
|
||||
"serverErrorOccurred": "Произошла ошибка сервера. Пожалуйста, попробуйте позже.",
|
||||
"fileSavedSuccessfully": "Файл успешно сохранен",
|
||||
"autoSaveFailed": "Автосохранение не удалось",
|
||||
"fileAutoSaved": "Файл автосохранен",
|
||||
"fileDownloadedSuccessfully": "Файл успешно скачан",
|
||||
"moveFileFailed": "Не удалось переместить {{name}}",
|
||||
"moveOperationFailed": "Операция перемещения не удалась",
|
||||
"canOnlyCompareFiles": "Можно сравнивать только два файла",
|
||||
@@ -1092,12 +1206,40 @@
|
||||
"sshConnectionFailed": "SSH-подключение не удалось. Пожалуйста, проверьте ваше подключение к {{name}} ({{ip}}:{{port}})",
|
||||
"loadFileFailed": "Не удалось загрузить файл: {{error}}",
|
||||
"connectedSuccessfully": "Успешно подключено",
|
||||
"totpVerificationFailed": "Проверка TOTP не удалась"
|
||||
"totpVerificationFailed": "Проверка TOTP не удалась",
|
||||
"andMoreFiles": "and {{count}} more...",
|
||||
"archiveExtractedSuccessfully": "{{name}} успешно извлечен",
|
||||
"archiveName": "Имя архива",
|
||||
"changePermissions": "Изменить права",
|
||||
"changePermissionsDesc": "Изменить права файла для",
|
||||
"compress": "Сжать",
|
||||
"compressFailed": "Сжатие не удалось",
|
||||
"compressFile": "Сжать файл",
|
||||
"compressFiles": "Сжать файлы",
|
||||
"compressFilesDesc": "Сжать {{count}} элементов в архив",
|
||||
"compressingFiles": "Сжатие {{count}} элементов в {{name}}...",
|
||||
"compressionFormat": "Формат сжатия",
|
||||
"currentPermissions": "Текущие права",
|
||||
"enterArchiveName": "Введите имя архива...",
|
||||
"execute": "Выполнить",
|
||||
"extractArchive": "Извлечь архив",
|
||||
"extractFailed": "Извлечение не удалось",
|
||||
"extractingArchive": "Извлечение {{name}}...",
|
||||
"failedToChangePermissions": "Не удалось изменить права",
|
||||
"filesCompressedSuccessfully": "{{name}} успешно создан",
|
||||
"group": "Группа",
|
||||
"newPermissions": "Новые права",
|
||||
"others": "Другие",
|
||||
"owner": "Владелец",
|
||||
"permissionsChangedSuccessfully": "Права успешно изменены",
|
||||
"read": "Чтение",
|
||||
"selectedFiles": "Выбранные файлы",
|
||||
"write": "Запись"
|
||||
},
|
||||
"tunnels": {
|
||||
"title": "SSH-туннели",
|
||||
"noSshTunnels": "Нет SSH-туннелей",
|
||||
"createFirstTunnelMessage": "Вы еще не создали SSH-туннели. Настройте туннельные подключения в Менеджере хостов, чтобы начать.",
|
||||
"createFirstTunnelMessage": "Создайте ваш первый SSH-туннель, чтобы начать. Используйте SSH-менеджер для добавления хостов с туннельными подключениями.",
|
||||
"connected": "Подключено",
|
||||
"disconnected": "Отключено",
|
||||
"connecting": "Подключение...",
|
||||
@@ -1138,17 +1280,8 @@
|
||||
"local": "Локальный",
|
||||
"remote": "Удаленный",
|
||||
"dynamic": "Динамический",
|
||||
"noSshTunnels": "Нет SSH-туннелей",
|
||||
"createFirstTunnelMessage": "Создайте ваш первый SSH-туннель, чтобы начать. Используйте SSH-менеджер для добавления хостов с туннельными подключениями.",
|
||||
"unknownConnectionStatus": "Неизвестно",
|
||||
"connected": "Подключено",
|
||||
"connecting": "Подключение...",
|
||||
"disconnecting": "Отключение...",
|
||||
"disconnected": "Отключено",
|
||||
"portMapping": "Порт {{sourcePort}} → {{endpointHost}}:{{endpointPort}}",
|
||||
"disconnect": "Отключить",
|
||||
"connect": "Подключить",
|
||||
"canceling": "Отмена...",
|
||||
"endpointHostNotFound": "Хост конечной точки не найден",
|
||||
"discord": "Discord",
|
||||
"githubIssue": "Проблема на GitHub",
|
||||
@@ -1161,7 +1294,7 @@
|
||||
"disk": "Диск",
|
||||
"network": "Сеть",
|
||||
"uptime": "Время работы",
|
||||
"loadAverage": "Средняя загрузка",
|
||||
"loadAverage": "Средняя: {{avg1}}, {{avg5}}, {{avg15}}",
|
||||
"processes": "Процессы",
|
||||
"connections": "Подключения",
|
||||
"usage": "Использование",
|
||||
@@ -1177,7 +1310,6 @@
|
||||
"cpuCores_one": "{{count}} CPU",
|
||||
"cpuCores_other": "{{count}} CPU",
|
||||
"naCpus": "N/A CPU",
|
||||
"loadAverage": "Средняя: {{avg1}}, {{avg5}}, {{avg15}}",
|
||||
"loadAverageNA": "Средняя: N/A",
|
||||
"cpuUsage": "Использование CPU",
|
||||
"memoryUsage": "Использование памяти",
|
||||
@@ -1196,8 +1328,6 @@
|
||||
"totpRequired": "Требуется TOTP-аутентификация",
|
||||
"totpUnavailable": "Статистика сервера недоступна для серверов с включенным TOTP",
|
||||
"load": "Загрузка",
|
||||
"free": "Свободно",
|
||||
"available": "Доступно",
|
||||
"editLayout": "Редактировать макет",
|
||||
"cancelEdit": "Отмена",
|
||||
"addWidget": "Добавить виджет",
|
||||
@@ -1215,9 +1345,27 @@
|
||||
"noInterfacesFound": "Сетевые интерфейсы не найдены",
|
||||
"totalProcesses": "Всего процессов",
|
||||
"running": "Запущено",
|
||||
"noProcessesFound": "Процессы не найдены"
|
||||
"noProcessesFound": "Процессы не найдены",
|
||||
"loginStats": "Статистика входов SSH",
|
||||
"totalLogins": "Всего входов",
|
||||
"uniqueIPs": "Уникальные IP",
|
||||
"recentSuccessfulLogins": "Последние успешные входы",
|
||||
"recentFailedAttempts": "Последние неудачные попытки",
|
||||
"noRecentLoginData": "Нет данных о недавних входах",
|
||||
"from": "с",
|
||||
"executeQuickAction": "Выполнить {{name}}",
|
||||
"executingQuickAction": "Выполнение {{name}}...",
|
||||
"quickActionError": "Не удалось выполнить {{name}}",
|
||||
"quickActionFailed": "{{name}} завершилось ошибкой",
|
||||
"quickActionSuccess": "{{name}} завершено успешно",
|
||||
"quickActions": "Быстрые действия"
|
||||
},
|
||||
"auth": {
|
||||
"tagline": "SSH ТЕРМИНАЛ МЕНЕДЖЕР",
|
||||
"description": "Безопасное, мощное и интуитивное управление SSH-соединениями",
|
||||
"welcomeBack": "Добро пожаловать обратно в TERMIX",
|
||||
"createAccount": "Создайте вашу учетную запись TERMIX",
|
||||
"continueExternal": "Продолжить с внешним провайдером",
|
||||
"loginTitle": "Вход в Termix",
|
||||
"registerTitle": "Создать учетную запись",
|
||||
"loginButton": "Войти",
|
||||
@@ -1300,7 +1448,27 @@
|
||||
"signUp": "Зарегистрироваться",
|
||||
"dataLossWarning": "Сброс пароля этим способом удалит все ваши сохраненные SSH-хосты, учетные данные и другие зашифрованные данные. Это действие нельзя отменить. Используйте это только если вы забыли пароль и не вошли в систему.",
|
||||
"authenticationDisabled": "Аутентификация отключена",
|
||||
"authenticationDisabledDesc": "Все методы аутентификации в настоящее время отключены. Пожалуйста, свяжитесь с вашим администратором."
|
||||
"authenticationDisabledDesc": "Все методы аутентификации в настоящее время отключены. Пожалуйста, свяжитесь с вашим администратором.",
|
||||
"authenticating": "Аутентификация...",
|
||||
"desktopApp": "Настольное приложение",
|
||||
"loadingServer": "Загрузка сервера...",
|
||||
"loggingInToDesktopApp": "Вход в настольное приложение",
|
||||
"loggingInToDesktopAppViaWeb": "Вход в настольное приложение через веб-интерфейс",
|
||||
"loggingInToMobileApp": "Вход в мобильное приложение",
|
||||
"mobileApp": "Мобильное приложение",
|
||||
"redirectingToApp": "Перенаправление в приложение...",
|
||||
"sshAuthFailedDescription": "Предоставленные учетные данные неверны. Пожалуйста, попробуйте снова с правильными учетными данными.",
|
||||
"sshAuthenticationFailed": "Аутентификация не удалась",
|
||||
"sshAuthenticationRequired": "Требуется SSH-аутентификация",
|
||||
"sshAuthenticationTimeout": "Тайм-аут аутентификации",
|
||||
"sshKeyPasswordDescription": "Если ваш SSH-ключ зашифрован, введите парольную фразу здесь.",
|
||||
"sshNoKeyboardInteractive": "Клавиатурная интерактивная аутентификация недоступна",
|
||||
"sshNoKeyboardInteractiveDescription": "Сервер не поддерживает клавиатурную интерактивную аутентификацию. Пожалуйста, укажите ваш пароль или SSH-ключ.",
|
||||
"sshPasswordDescription": "Введите пароль для этого SSH-подключения.",
|
||||
"sshProvideCredentialsDescription": "Пожалуйста, предоставьте ваши SSH-учетные данные для подключения к этому серверу.",
|
||||
"sshTimeoutDescription": "Попытка аутентификации истекла по времени. Пожалуйста, попробуйте снова.",
|
||||
"passwordResetSuccess": "Сброс пароля прошел успешно",
|
||||
"passwordResetSuccessDesc": "Ваш пароль был успешно сброшен. Теперь вы можете войти с новым паролем."
|
||||
},
|
||||
"errors": {
|
||||
"notFound": "Страница не найдена",
|
||||
@@ -1373,9 +1541,16 @@
|
||||
"local": "Локальный",
|
||||
"external": "Внешний (OIDC)",
|
||||
"selectPreferredLanguage": "Выберите предпочитаемый язык интерфейса",
|
||||
"fileColorCoding": "Цветовое кодирование файлов",
|
||||
"fileColorCodingDesc": "Цветовая кодировка файлов по типу: папки (красный), файлы (синий), символические ссылки (зелёный)",
|
||||
"commandAutocomplete": "Автодополнение команд",
|
||||
"commandAutocompleteDesc": "Включить автодополнение команд терминала клавишей Tab на основе вашей истории команд",
|
||||
"defaultSnippetFoldersCollapsed": "Сворачивать папки сниппетов по умолчанию",
|
||||
"defaultSnippetFoldersCollapsedDesc": "Если включено, все папки сниппетов будут свёрнуты при открытии вкладки сниппетов",
|
||||
"currentPassword": "Текущий пароль",
|
||||
"passwordChangedSuccess": "Пароль успешно изменен! Пожалуйста, войдите снова.",
|
||||
"failedToChangePassword": "Не удалось изменить пароль. Пожалуйста, проверьте ваш текущий пароль и попробуйте снова."
|
||||
"failedToChangePassword": "Не удалось изменить пароль. Пожалуйста, проверьте ваш текущий пароль и попробуйте снова.",
|
||||
"externalAndLocal": "Двойная аутентификация"
|
||||
},
|
||||
"user": {
|
||||
"failedToLoadVersionInfo": "Не удалось загрузить информацию о версии"
|
||||
@@ -1438,7 +1613,8 @@
|
||||
"lastAdminWarning": "Вы последний пользователь-администратор. Вы не можете удалить свою учетную запись, так как это оставит систему без администраторов. Пожалуйста, сначала сделайте другого пользователя администратором или свяжитесь с поддержкой системы.",
|
||||
"confirmPassword": "Подтвердите пароль",
|
||||
"deleting": "Удаление...",
|
||||
"cancel": "Отмена"
|
||||
"cancel": "Отмена",
|
||||
"deleteAccountWarningShort": "Это действие необратимо и приведет к окончательному удалению вашей учетной записи."
|
||||
},
|
||||
"interface": {
|
||||
"sidebar": "Боковая панель",
|
||||
@@ -1458,7 +1634,6 @@
|
||||
"deleteItem": "Удалить элемент",
|
||||
"createNewFile": "Создать новый файл",
|
||||
"createNewFolder": "Создать новую папку",
|
||||
"deleteItem": "Удалить элемент",
|
||||
"renameItem": "Переименовать элемент",
|
||||
"clickToSelectFile": "Нажмите для выбора файла",
|
||||
"noSshHosts": "Нет SSH-хостов",
|
||||
@@ -1587,5 +1762,28 @@
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM",
|
||||
"notAvailable": "N/A"
|
||||
},
|
||||
"commandPalette": {
|
||||
"searchPlaceholder": "Поиск хостов или быстрых действий...",
|
||||
"recentActivity": "Недавняя активность",
|
||||
"navigation": "Навигация",
|
||||
"addHost": "Добавить хост",
|
||||
"addCredential": "Добавить учетные данные",
|
||||
"adminSettings": "Настройки администратора",
|
||||
"userProfile": "Профиль пользователя",
|
||||
"updateLog": "Журнал обновлений",
|
||||
"hosts": "Хосты",
|
||||
"openServerDetails": "Открыть детали сервера",
|
||||
"openFileManager": "Открыть файловый менеджер",
|
||||
"edit": "Редактировать",
|
||||
"links": "Ссылки",
|
||||
"github": "GitHub",
|
||||
"support": "Поддержка",
|
||||
"discord": "Discord",
|
||||
"donate": "Пожертвовать",
|
||||
"press": "Нажмите",
|
||||
"toToggle": "для переключения",
|
||||
"close": "Закрыть",
|
||||
"hostManager": "Менеджер хостов"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"credentials": {
|
||||
"credentialsViewer": "凭证查看器",
|
||||
"credentialsManager": "凭据管理器",
|
||||
"manageYourSSHCredentials": "安全管理您的SSH凭据",
|
||||
"addCredential": "添加凭据",
|
||||
"createCredential": "创建凭据",
|
||||
@@ -164,7 +163,9 @@
|
||||
"failedToGenerateKeyPair": "生成密钥对失败",
|
||||
"generateKeyPairNote": "直接生成新的SSH密钥对。这将替换表单中的现有密钥。",
|
||||
"invalidKey": "无效密钥",
|
||||
"detectionError": "检测错误"
|
||||
"detectionError": "检测错误",
|
||||
"credentialId": "凭据 ID",
|
||||
"unknown": "未知"
|
||||
},
|
||||
"dragIndicator": {
|
||||
"error": "错误:{{error}}",
|
||||
@@ -223,6 +224,20 @@
|
||||
"editTooltip": "编辑此片段",
|
||||
"deleteTooltip": "删除此片段"
|
||||
},
|
||||
"commandHistory": {
|
||||
"title": "历史记录",
|
||||
"searchPlaceholder": "搜索命令...",
|
||||
"noTerminal": "无活动终端",
|
||||
"noTerminalHint": "打开终端以查看其命令历史记录。",
|
||||
"empty": "暂无命令历史记录",
|
||||
"emptyHint": "在活动终端中执行命令以建立历史记录。",
|
||||
"noResults": "未找到命令",
|
||||
"noResultsHint": "没有匹配 \"{{query}}\" 的命令",
|
||||
"deleteSuccess": "命令已从历史记录中删除",
|
||||
"deleteFailed": "删除命令失败。",
|
||||
"deleteTooltip": "删除命令",
|
||||
"tabHint": "在终端中使用 Tab 键从命令历史记录自动完成"
|
||||
},
|
||||
"homepage": {
|
||||
"loggedInTitle": "登录成功!",
|
||||
"loggedInMessage": "您已登录!使用侧边栏访问所有可用工具。要开始使用,请在 SSH 管理器选项卡中创建 SSH 主机。创建后,您可以使用侧边栏中的其他应用程序连接到该主机。",
|
||||
@@ -245,7 +260,11 @@
|
||||
"saveError": "保存配置时出错",
|
||||
"saving": "保存中...",
|
||||
"saveConfig": "保存配置",
|
||||
"helpText": "输入您的 Termix 服务器运行地址(例如:http://localhost:30001 或 https://your-server.com)"
|
||||
"helpText": "输入您的 Termix 服务器运行地址(例如:http://localhost:30001 或 https://your-server.com)",
|
||||
"changeServer": "更换服务器",
|
||||
"mustIncludeProtocol": "服务器URL必须以 http:// 或 https:// 开头",
|
||||
"notValidatedWarning": "URL 未经验证 - 请确保其正确",
|
||||
"warning": "警告"
|
||||
},
|
||||
"versionCheck": {
|
||||
"error": "版本检查错误",
|
||||
@@ -321,13 +340,11 @@
|
||||
"login": "登录",
|
||||
"logout": "登出",
|
||||
"register": "注册",
|
||||
"username": "用户名",
|
||||
"password": "密码",
|
||||
"confirmPassword": "确认密码",
|
||||
"back": "返回",
|
||||
"email": "邮箱",
|
||||
"submit": "提交",
|
||||
"cancel": "取消",
|
||||
"change": "更改",
|
||||
"save": "保存",
|
||||
"delete": "删除",
|
||||
@@ -368,7 +385,9 @@
|
||||
"documentation": "文档",
|
||||
"retry": "重试",
|
||||
"checking": "检查中...",
|
||||
"checkingDatabase": "正在检查数据库连接..."
|
||||
"checkingDatabase": "正在检查数据库连接...",
|
||||
"saving": "保存中...",
|
||||
"version": "Version"
|
||||
},
|
||||
"nav": {
|
||||
"home": "首页",
|
||||
@@ -497,7 +516,7 @@
|
||||
"loadingEncryptionStatus": "正在加载加密状态...",
|
||||
"testMigrationDescription": "验证现有数据是否可以安全地迁移到加密格式,不会实际修改任何数据",
|
||||
"serverMigrationGuide": "服务器迁移指南",
|
||||
"migrationInstructions": "要将加密数据迁移到新服务器:1) 备份数据库文件,2) 在新服务器设置环境变量 DB_ENCRYPTION_KEY=\"你的密钥\",3) 恢复数据库文件",
|
||||
"migrationInstructions": "要将加密数据迁移到新服务器:1) 备份数据库文件,2) 在新服务器设置环境变量 DB_ENCRYPTION_KEY=\"你的key\",3) 恢复数据库文件",
|
||||
"environmentProtection": "环境保护",
|
||||
"environmentProtectionDesc": "基于服务器环境信息(主机名、路径等)保护加密密钥,可通过环境变量实现迁移",
|
||||
"verificationCompleted": "兼容性验证完成 - 未修改任何数据",
|
||||
@@ -581,7 +600,32 @@
|
||||
"passwordLoginDisabledWarning": "密码登录已禁用。请确保 OIDC 已正确配置,否则您将无法登录 Termix。",
|
||||
"oidcRequiredWarning": "严重警告:密码登录已禁用。如果您重置或错误配置 OIDC,您将失去对 Termix 的所有访问权限并使您的实例无法使用。只有在您完全确定的情况下才能继续。",
|
||||
"confirmDisableOIDCWarning": "警告:您即将在密码登录也已禁用的情况下禁用 OIDC。这将使您的 Termix 实例无法使用,您将失去所有访问权限。您确定要继续吗?",
|
||||
"failedToUpdatePasswordLoginStatus": "更新密码登录状态失败"
|
||||
"failedToUpdatePasswordLoginStatus": "更新密码登录状态失败",
|
||||
"accountsLinkedSuccessfully": "OIDC 用户 {{oidcUsername}} 已关联到 {{targetUsername}}",
|
||||
"confirmRevokeAllSessions": "您确定要撤销此用户的所有会话吗?",
|
||||
"confirmRevokeSession": "您确定要撤销此会话吗?",
|
||||
"failedToFetchSessions": "获取会话失败",
|
||||
"failedToLinkAccounts": "关联账户失败",
|
||||
"failedToRevokeSession": "撤销会话失败",
|
||||
"failedToRevokeSessions": "撤销会话失败",
|
||||
"failedToUnlinkOIDC": "取消 OIDC 关联失败",
|
||||
"linkAccountsButton": "关联账户",
|
||||
"linkOIDCActionAddCapability": "将 OIDC 登录功能添加到目标密码账户",
|
||||
"linkOIDCActionDeleteUser": "删除 OIDC 用户账户及其所有数据",
|
||||
"linkOIDCActionDualAuth": "允许密码账户同时使用密码和 OIDC 登录",
|
||||
"linkOIDCDialogDescription": "将 {{username}} (OIDC 用户) 关联到现有的密码账户。这将为密码账户启用双重认证。",
|
||||
"linkOIDCDialogTitle": "将 OIDC 账户关联到密码账户",
|
||||
"linkOIDCWarningTitle": "警告: OIDC 用户数据将被删除",
|
||||
"linkTargetUsernameLabel": "目标密码账户用户名",
|
||||
"linkTargetUsernamePlaceholder": "输入密码账户的用户名",
|
||||
"linkTargetUsernameRequired": "目标用户名是必需的",
|
||||
"linkToPasswordAccount": "关联到密码账户",
|
||||
"linkingAccounts": "关联中...",
|
||||
"sessionRevokedSuccessfully": "会话撤销成功",
|
||||
"sessionsRevokedSuccessfully": "会话撤销成功",
|
||||
"unlinkOIDCDescription": "移除 {{username}} 的 OIDC 认证?此操作后用户只能使用用户名/密码登录。",
|
||||
"unlinkOIDCSuccess": "已取消 {{username}} 的 OIDC 关联",
|
||||
"unlinkOIDCTitle": "取消 OIDC 认证关联"
|
||||
},
|
||||
"hosts": {
|
||||
"title": "主机管理",
|
||||
@@ -618,7 +662,6 @@
|
||||
"port": "端口",
|
||||
"name": "名称",
|
||||
"username": "用户名",
|
||||
"hostName": "主机名",
|
||||
"folder": "文件夹",
|
||||
"tags": "标签",
|
||||
"passwordRequired": "使用密码认证时需要密码",
|
||||
@@ -628,10 +671,6 @@
|
||||
"addHost": "添加主机",
|
||||
"editHost": "编辑主机",
|
||||
"cloneHost": "克隆主机",
|
||||
"deleteHost": "删除主机",
|
||||
"authType": "认证类型",
|
||||
"passwordAuth": "密码",
|
||||
"keyAuth": "SSH 密钥",
|
||||
"keyPassword": "密钥密码",
|
||||
"keyType": "密钥类型",
|
||||
"pin": "固定",
|
||||
@@ -639,15 +678,6 @@
|
||||
"enableTunnel": "启用隧道",
|
||||
"enableFileManager": "启用文件管理器",
|
||||
"defaultPath": "默认路径",
|
||||
"testConnection": "测试连接",
|
||||
"connect": "连接",
|
||||
"disconnect": "断开连接",
|
||||
"connected": "已连接",
|
||||
"disconnected": "已断开",
|
||||
"connecting": "连接中...",
|
||||
"connectionFailed": "连接失败",
|
||||
"connectionSuccess": "连接成功",
|
||||
"addTags": "添加标签(空格添加)",
|
||||
"sourcePort": "源端口",
|
||||
"sourcePortDesc": "(源指通用标签页中的当前连接详情)",
|
||||
"endpointPort": "目标端口",
|
||||
@@ -657,20 +687,7 @@
|
||||
"remove": "移除",
|
||||
"addConnection": "添加连接",
|
||||
"sshpassRequired": "密码认证需要安装 Sshpass",
|
||||
"sshpassInstallCommand": "安装命令:sudo apt install sshpass",
|
||||
"sshServerConfig": "需要配置 SSH 服务器",
|
||||
"sshServerConfigInstructions": "运行以下命令以允许密码认证:",
|
||||
"sshConfigCommand1": "sudo sed -i 's/#PasswordAuthentication yes/PasswordAuthentication yes/' /etc/ssh/sshd_config",
|
||||
"sshConfigCommand2": "sudo systemctl restart sshd",
|
||||
"localPortForwarding": "本地端口转发",
|
||||
"localPortForwardingDesc": "通过 SSH 连接将本地端口转发到远程服务器",
|
||||
"remotePortForwarding": "远程端口转发",
|
||||
"remotePortForwardingDesc": "通过 SSH 连接将远程端口转发到本地服务器",
|
||||
"dynamicPortForwarding": "动态端口转发(SOCKS 代理)",
|
||||
"dynamicPortForwardingDesc": "在本地计算机上创建 SOCKS 代理,通过 SSH 连接路由流量",
|
||||
"bindAddress": "绑定地址",
|
||||
"hostViewer": "主机查看器",
|
||||
"configuration": "配置",
|
||||
"maxRetries": "最大重试次数",
|
||||
"tunnelConnections": "隧道连接",
|
||||
"enableTerminalDesc": "启用/禁用在终端选项卡中显示此主机",
|
||||
@@ -679,8 +696,6 @@
|
||||
"autoStartDesc": "容器启动时自动启动此隧道",
|
||||
"defaultPathDesc": "打开此主机文件管理器时的默认目录",
|
||||
"tunnelForwardDescription": "此隧道将从源计算机(常规选项卡中的当前连接详情)的端口 {{sourcePort}} 转发流量到端点计算机的端口 {{endpointPort}}。",
|
||||
"endpointSshConfiguration": "端点 SSH 配置",
|
||||
"sourcePortDescription": "(源指的是常规选项卡中的当前连接详情)",
|
||||
"autoStartContainer": "容器启动时自动启动",
|
||||
"upload": "上传",
|
||||
"authentication": "认证方式",
|
||||
@@ -701,20 +716,12 @@
|
||||
"centosRhelFedora": "CentOS/RHEL/Fedora",
|
||||
"macos": "macOS",
|
||||
"windows": "Windows",
|
||||
"sshpassOSInstructions": {
|
||||
"centos": "CentOS/RHEL/Fedora: sudo yum install sshpass 或 sudo dnf install sshpass",
|
||||
"macos": "macOS: brew install hudochenkov/sshpass/sshpass",
|
||||
"windows": "Windows: 使用 WSL 或考虑使用 SSH 密钥认证"
|
||||
},
|
||||
"sshpassOSInstructions": {},
|
||||
"sshServerConfigRequired": "SSH 服务器配置要求",
|
||||
"sshServerConfigDesc": "对于隧道连接,SSH 服务器必须配置允许端口转发:",
|
||||
"gatewayPortsYes": "绑定远程端口到所有接口",
|
||||
"allowTcpForwardingYes": "启用端口转发",
|
||||
"permitRootLoginYes": "如果使用 root 用户进行隧道连接",
|
||||
"sshServerConfigReverse": "对于反向 SSH 隧道,端点 SSH 服务器必须允许:",
|
||||
"gatewayPorts": "GatewayPorts yes(绑定远程端口)",
|
||||
"allowTcpForwarding": "AllowTcpForwarding yes(端口转发)",
|
||||
"permitRootLogin": "PermitRootLogin yes(如果使用 root)",
|
||||
"editSshConfig": "编辑 /etc/ssh/sshd_config 并重启 SSH: sudo systemctl restart sshd",
|
||||
"updateHost": "更新主机",
|
||||
"hostUpdatedSuccessfully": "主机 \"{{name}}\" 更新成功!",
|
||||
@@ -744,7 +751,6 @@
|
||||
"tunnel": "隧道",
|
||||
"fileManager": "文件管理器",
|
||||
"serverStats": "服务器统计",
|
||||
"hostViewer": "主机查看器",
|
||||
"enableServerStats": "启用服务器统计",
|
||||
"enableServerStatsDesc": "启用/禁用此主机的服务器统计信息收集",
|
||||
"displayItems": "显示项目",
|
||||
@@ -766,6 +772,17 @@
|
||||
"failedToRemoveFromFolder": "从文件夹中移除主机失败",
|
||||
"folderRenamed": "文件夹\"{{oldName}}\"已成功重命名为\"{{newName}}\"",
|
||||
"failedToRenameFolder": "重命名文件夹失败",
|
||||
"editFolderAppearance": "编辑文件夹外观",
|
||||
"editFolderAppearanceDesc": "自定义文件夹的颜色和图标",
|
||||
"folderColor": "文件夹颜色",
|
||||
"folderIcon": "文件夹图标",
|
||||
"preview": "预览",
|
||||
"folderAppearanceUpdated": "文件夹外观更新成功",
|
||||
"failedToUpdateFolderAppearance": "更新文件夹外观失败",
|
||||
"deleteAllHostsInFolder": "删除文件夹内所有主机",
|
||||
"confirmDeleteAllHostsInFolder": "确定要删除文件夹\"{{folder}}\"中的全部 {{count}} 个主机吗?此操作无法撤销。",
|
||||
"allHostsInFolderDeleted": "已成功从文件夹\"{{folder}}\"删除 {{count}} 个主机",
|
||||
"failedToDeleteHostsInFolder": "删除文件夹中的主机失败",
|
||||
"movedToFolder": "主机\"{{name}}\"已成功移动到\"{{folder}}\"",
|
||||
"failedToMoveToFolder": "移动主机到文件夹失败",
|
||||
"statistics": "统计",
|
||||
@@ -790,17 +807,99 @@
|
||||
"statusMonitoring": "状态",
|
||||
"metricsMonitoring": "指标",
|
||||
"terminalCustomizationNotice": "注意:终端自定义仅在桌面网站版本中有效。移动和 Electron 应用程序使用系统默认终端设置。",
|
||||
"terminalCustomization": "终端自定义",
|
||||
"appearance": "外观",
|
||||
"behavior": "行为",
|
||||
"advanced": "高级",
|
||||
"themePreview": "主题预览",
|
||||
"theme": "主题",
|
||||
"selectTheme": "选择主题",
|
||||
"chooseColorTheme": "选择终端的颜色主题",
|
||||
"fontFamily": "字体系列",
|
||||
"selectFont": "选择字体",
|
||||
"selectFontDesc": "选择终端使用的字体",
|
||||
"fontSize": "字体大小",
|
||||
"fontSizeValue": "字体大小:{{value}}px",
|
||||
"adjustFontSize": "调整终端字体大小",
|
||||
"letterSpacing": "字母间距",
|
||||
"letterSpacingValue": "字母间距:{{value}}px",
|
||||
"adjustLetterSpacing": "调整字符之间的间距",
|
||||
"lineHeight": "行高",
|
||||
"lineHeightValue": "行高:{{value}}",
|
||||
"adjustLineHeight": "调整行之间的间距",
|
||||
"cursorStyle": "光标样式",
|
||||
"selectCursorStyle": "选择光标样式",
|
||||
"cursorStyleBlock": "块状",
|
||||
"cursorStyleUnderline": "下划线",
|
||||
"cursorStyleBar": "竖线",
|
||||
"chooseCursorAppearance": "选择光标外观",
|
||||
"cursorBlink": "光标闪烁",
|
||||
"enableCursorBlink": "启用光标闪烁动画",
|
||||
"scrollbackBuffer": "回滚缓冲区",
|
||||
"scrollbackBufferValue": "回滚缓冲区:{{value}} 行",
|
||||
"scrollbackBufferDesc": "保留在回滚历史记录中的行数",
|
||||
"bellStyle": "铃声样式",
|
||||
"selectBellStyle": "选择铃声样式",
|
||||
"bellStyleNone": "无",
|
||||
"bellStyleSound": "声音",
|
||||
"bellStyleVisual": "视觉",
|
||||
"bellStyleBoth": "两者",
|
||||
"bellStyleDesc": "如何处理终端铃声(BEL字符,\\x07)。程序在完成任务、遇到错误或通知时会触发此功能。\"声音\"播放音频提示音,\"视觉\"短暂闪烁屏幕,\"两者\"同时执行,\"无\"禁用铃声提醒。",
|
||||
"rightClickSelectsWord": "右键选择单词",
|
||||
"rightClickSelectsWordDesc": "右键单击选择光标下的单词",
|
||||
"fastScrollModifier": "快速滚动修饰键",
|
||||
"selectModifier": "选择修饰键",
|
||||
"modifierAlt": "Alt",
|
||||
"modifierCtrl": "Ctrl",
|
||||
"modifierShift": "Shift",
|
||||
"fastScrollModifierDesc": "快速滚动的修饰键",
|
||||
"fastScrollSensitivity": "快速滚动灵敏度",
|
||||
"fastScrollSensitivityValue": "快速滚动灵敏度:{{value}}",
|
||||
"fastScrollSensitivityDesc": "按住修饰键时的滚动速度倍数",
|
||||
"minimumContrastRatio": "最小对比度",
|
||||
"minimumContrastRatioValue": "最小对比度:{{value}}",
|
||||
"minimumContrastRatioDesc": "自动调整颜色以获得更好的可读性",
|
||||
"sshAgentForwarding": "SSH 代理转发",
|
||||
"sshAgentForwardingDesc": "将 SSH 身份验证代理转发到远程主机",
|
||||
"backspaceMode": "退格模式",
|
||||
"selectBackspaceMode": "选择退格模式",
|
||||
"backspaceModeNormal": "正常 (DEL)",
|
||||
"backspaceModeControlH": "Control-H (^H)",
|
||||
"backspaceModeDesc": "退格键行为兼容性",
|
||||
"startupSnippet": "启动代码片段",
|
||||
"selectSnippet": "选择代码片段",
|
||||
"searchSnippets": "搜索代码片段...",
|
||||
"snippetNone": "无",
|
||||
"noneAuthTitle": "键盘交互式认证",
|
||||
"noneAuthDescription": "此认证方法在连接到 SSH 服务器时将使用键盘交互式认证。",
|
||||
"noneAuthDetails": "键盘交互式认证允许服务器在连接期间提示您输入凭据。这对于需要多因素认证或动态密码输入的服务器很有用。",
|
||||
"forceKeyboardInteractive": "强制键盘交互式认证",
|
||||
"forceKeyboardInteractiveDesc": "强制使用键盘交互式认证。这通常是使用双因素认证(TOTP/2FA)的服务器所必需的。"
|
||||
"forceKeyboardInteractiveDesc": "强制使用键盘交互式认证。这通常是使用双因素认证(TOTP/2FA)的服务器所必需的。",
|
||||
"overrideCredentialUsername": "覆盖凭据用户名",
|
||||
"overrideCredentialUsernameDesc": "使用与凭据中存储的用户名不同的用户名。这允许您对不同的用户名使用相同的凭据。",
|
||||
"jumpHosts": "跳板主机",
|
||||
"jumpHostsDescription": "跳板主机(也称为堡垒主机)允许您通过一个或多个中间服务器连接到目标服务器。这对于访问防火墙后或私有网络中的服务器很有用。",
|
||||
"jumpHostChain": "跳板主机链",
|
||||
"addJumpHost": "添加跳板主机",
|
||||
"selectServer": "选择服务器",
|
||||
"searchServers": "搜索服务器...",
|
||||
"noServerFound": "未找到服务器",
|
||||
"jumpHostsOrder": "连接将按顺序进行:跳板主机 1 → 跳板主机 2 → ... → 目标服务器",
|
||||
"advancedAuthSettings": "高级身份验证设置",
|
||||
"addQuickAction": "添加 Quick Action",
|
||||
"noSnippetFound": "没有 snippet found",
|
||||
"quickActionName": "Action 名称",
|
||||
"quickActions": "Quick Actions",
|
||||
"quickActionsDescription": "Quick actions allow you to 创建 custom buttons that execute SSH snippets on this server. These buttons will appear at the top of the Server Stats page for quick access.",
|
||||
"quickActionsList": "Quick Actions List",
|
||||
"quickActionsOrder": "Quick action buttons will appear in the order listed above on the Server Stats page",
|
||||
"sshpassRequiredDesc": "For 密码 认证 in tunnels, sshpass must be installed on the system."
|
||||
},
|
||||
"terminal": {
|
||||
"title": "终端",
|
||||
"terminalTitle": "终端 - {{host}}",
|
||||
"terminalWithPath": "终端 - {{host}}:{{path}}",
|
||||
"runTitle": "运行 {{command}} - {{host}}",
|
||||
"runTitle": "运行 {{command}} - {{name}}",
|
||||
"totpRequired": "需要双因素认证",
|
||||
"totpCodeLabel": "验证码",
|
||||
"totpPlaceholder": "000000",
|
||||
@@ -832,7 +931,11 @@
|
||||
"reconnecting": "重新连接中... ({{attempt}}/{{max}})",
|
||||
"reconnected": "重新连接成功",
|
||||
"maxReconnectAttemptsReached": "已达到最大重连尝试次数",
|
||||
"connectionTimeout": "连接超时"
|
||||
"connectionTimeout": "连接超时",
|
||||
"sudoPasswordPopupTitle": "插入密码?",
|
||||
"sudoPasswordPopupHint": "按 Enter 插入,Esc 取消",
|
||||
"sudoPasswordPopupConfirm": "插入",
|
||||
"sudoPasswordPopupDismiss": "取消"
|
||||
},
|
||||
"fileManager": {
|
||||
"title": "文件管理器",
|
||||
@@ -841,6 +944,22 @@
|
||||
"connectToSsh": "连接 SSH 以使用文件操作",
|
||||
"uploadFile": "上传文件",
|
||||
"downloadFile": "下载",
|
||||
"extractArchive": "解压文件",
|
||||
"extractingArchive": "正在解压 {{name}}...",
|
||||
"archiveExtractedSuccessfully": "{{name}} 解压成功",
|
||||
"extractFailed": "解压失败",
|
||||
"compressFile": "压缩文件",
|
||||
"compressFiles": "压缩文件",
|
||||
"compressFilesDesc": "将 {{count}} 个项目压缩为归档文件",
|
||||
"archiveName": "归档文件名",
|
||||
"enterArchiveName": "输入归档文件名...",
|
||||
"compressionFormat": "压缩格式",
|
||||
"selectedFiles": "已选文件",
|
||||
"andMoreFiles": "以及其他 {{count}} 个...",
|
||||
"compress": "压缩",
|
||||
"compressingFiles": "正在将 {{count}} 个项目压缩到 {{name}}...",
|
||||
"filesCompressedSuccessfully": "{{name}} 创建成功",
|
||||
"compressFailed": "压缩失败",
|
||||
"edit": "编辑",
|
||||
"preview": "预览",
|
||||
"previous": "上一页",
|
||||
@@ -910,7 +1029,11 @@
|
||||
"noSSHConnection": "无SSH连接可用",
|
||||
"enterFolderName": "输入文件夹名称:",
|
||||
"enterFileName": "输入文件名称:",
|
||||
"copy": "复制",
|
||||
"cut": "剪切",
|
||||
"paste": "粘贴",
|
||||
"copyPath": "复制路径",
|
||||
"copyPaths": "复制路径",
|
||||
"properties": "属性",
|
||||
"refresh": "刷新",
|
||||
"downloadFiles": "下载 {{count}} 个文件",
|
||||
@@ -919,6 +1042,9 @@
|
||||
"deleteFiles": "删除 {{count}} 个项目",
|
||||
"filesCopiedToClipboard": "{{count}} 个项目已复制到剪贴板",
|
||||
"filesCutToClipboard": "{{count}} 个项目已剪切到剪贴板",
|
||||
"pathCopiedToClipboard": "路径已复制到剪贴板",
|
||||
"pathsCopiedToClipboard": "{{count}} 个路径已复制到剪贴板",
|
||||
"failedToCopyPath": "复制路径到剪贴板失败",
|
||||
"movedItems": "已移动 {{count}} 个项目",
|
||||
"unknownSize": "未知大小",
|
||||
"fileIsEmpty": "文件为空",
|
||||
@@ -1088,7 +1214,28 @@
|
||||
"sshConnectionFailed": "SSH 连接失败。请检查与 {{name}} ({{ip}}:{{port}}) 的连接",
|
||||
"loadFileFailed": "加载文件失败:{{error}}",
|
||||
"connectedSuccessfully": "连接成功",
|
||||
"totpVerificationFailed": "TOTP 验证失败"
|
||||
"totpVerificationFailed": "TOTP 验证失败",
|
||||
"changePermissions": "修改权限",
|
||||
"changePermissionsDesc": "修改文件权限",
|
||||
"currentPermissions": "当前权限",
|
||||
"newPermissions": "新权限",
|
||||
"owner": "所有者",
|
||||
"group": "组",
|
||||
"others": "其他",
|
||||
"read": "读取",
|
||||
"write": "写入",
|
||||
"execute": "执行",
|
||||
"permissionsChangedSuccessfully": "权限修改成功",
|
||||
"failedToChangePermissions": "权限修改失败",
|
||||
"autoSaveFailed": "自动保存失败",
|
||||
"delete": "删除",
|
||||
"download": "下载",
|
||||
"fileAutoSaved": "文件已自动保存",
|
||||
"fileDownloadedSuccessfully": "文件 \"{{name}}\" 下载成功",
|
||||
"fileSavedSuccessfully": "文件保存成功",
|
||||
"path": "Path",
|
||||
"permissions": "Permissions",
|
||||
"size": "Size"
|
||||
},
|
||||
"tunnels": {
|
||||
"title": "SSH 隧道",
|
||||
@@ -1138,7 +1285,8 @@
|
||||
"endpointHostNotFound": "未找到端点主机",
|
||||
"discord": "Discord",
|
||||
"githubIssue": "GitHub 问题",
|
||||
"forHelp": "寻求帮助"
|
||||
"forHelp": "寻求帮助",
|
||||
"unknownConnectionStatus": "Unk没有wn"
|
||||
},
|
||||
"serverStats": {
|
||||
"title": "服务器统计",
|
||||
@@ -1180,8 +1328,6 @@
|
||||
"totpRequired": "需要 TOTP 认证",
|
||||
"totpUnavailable": "启用了 TOTP 的服务器无法使用服务器统计功能",
|
||||
"load": "负载",
|
||||
"free": "空闲",
|
||||
"available": "可用",
|
||||
"editLayout": "编辑布局",
|
||||
"cancelEdit": "取消",
|
||||
"addWidget": "添加小组件",
|
||||
@@ -1199,9 +1345,28 @@
|
||||
"noInterfacesFound": "未找到网络接口",
|
||||
"totalProcesses": "总进程数",
|
||||
"running": "运行中",
|
||||
"noProcessesFound": "未找到进程"
|
||||
"noProcessesFound": "未找到进程",
|
||||
"loginStats": "SSH 登录统计",
|
||||
"totalLogins": "总登录次数",
|
||||
"uniqueIPs": "唯一 IP 数",
|
||||
"recentSuccessfulLogins": "最近成功登录",
|
||||
"recentFailedAttempts": "最近失败尝试",
|
||||
"noRecentLoginData": "无最近登录数据",
|
||||
"from": "来自",
|
||||
"executeQuickAction": "执行 {{name}}",
|
||||
"executingQuickAction": "执行中 {{name}}...",
|
||||
"failedToFetchHomeData": "获取主页数据失败",
|
||||
"quickActionError": "无法执行 {{name}}",
|
||||
"quickActionFailed": "{{name}} 失败",
|
||||
"quickActionSuccess": "{{name}} 完成成功",
|
||||
"quickActions": "Quick Actions"
|
||||
},
|
||||
"auth": {
|
||||
"tagline": "SSH 终端管理器",
|
||||
"description": "安全、强大、直观的 SSH 连接管理",
|
||||
"welcomeBack": "欢迎回到 TERMIX",
|
||||
"createAccount": "创建您的 TERMIX 账户",
|
||||
"continueExternal": "使用外部提供商继续",
|
||||
"loginTitle": "登录 Termix",
|
||||
"registerTitle": "创建账户",
|
||||
"loginButton": "登录",
|
||||
@@ -1294,7 +1459,17 @@
|
||||
"sshTimeoutDescription": "身份验证尝试超时。请重试。",
|
||||
"sshProvideCredentialsDescription": "请提供您的 SSH 凭据以连接到此服务器。",
|
||||
"sshPasswordDescription": "输入此 SSH 连接的密码。",
|
||||
"sshKeyPasswordDescription": "如果您的 SSH 密钥已加密,请在此处输入密码。"
|
||||
"sshKeyPasswordDescription": "如果您的 SSH 密钥已加密,请在此处输入密码。",
|
||||
"authenticating": "Authenticating...",
|
||||
"authenticationDisabled": "认证已禁用",
|
||||
"authenticationDisabledDesc": "所有认证方式当前已禁用。请联系您的管理员。",
|
||||
"desktopApp": "桌面应用",
|
||||
"loadingServer": "加载服务器中...",
|
||||
"loggingInToDesktopApp": "登录桌面应用",
|
||||
"loggingInToDesktopAppViaWeb": "通过网页界面登录桌面应用",
|
||||
"loggingInToMobileApp": "登录移动应用",
|
||||
"mobileApp": "移动应用",
|
||||
"redirectingToApp": "重定向到应用..."
|
||||
},
|
||||
"errors": {
|
||||
"notFound": "页面未找到",
|
||||
@@ -1367,9 +1542,16 @@
|
||||
"local": "本地",
|
||||
"external": "外部 (OIDC)",
|
||||
"selectPreferredLanguage": "选择您的界面首选语言",
|
||||
"fileColorCoding": "文件颜色编码",
|
||||
"fileColorCodingDesc": "按类型对文件进行颜色编码:文件夹(红色)、文件(蓝色)、符号链接(绿色)",
|
||||
"commandAutocomplete": "命令自动补全",
|
||||
"commandAutocompleteDesc": "启用基于命令历史记录的 Tab 键终端命令自动补全建议",
|
||||
"defaultSnippetFoldersCollapsed": "默认折叠代码片段文件夹",
|
||||
"defaultSnippetFoldersCollapsedDesc": "启用后,打开代码片段标签时所有文件夹将默认折叠",
|
||||
"currentPassword": "当前密码",
|
||||
"passwordChangedSuccess": "密码修改成功!请重新登录。",
|
||||
"failedToChangePassword": "修改密码失败。请检查您当前的密码并重试。"
|
||||
"failedToChangePassword": "修改密码失败。请检查您当前的密码并重试。",
|
||||
"externalAndLocal": "Dual Auth"
|
||||
},
|
||||
"user": {
|
||||
"failedToLoadVersionInfo": "加载版本信息失败"
|
||||
@@ -1399,7 +1581,7 @@
|
||||
"redirectUrl": "https://your-provider.com/application/o/termix/",
|
||||
"tokenUrl": "https://your-provider.com/application/o/token/",
|
||||
"userIdField": "sub",
|
||||
"usernameField": "name",
|
||||
"usernameField": "名称",
|
||||
"scopes": "openid email profile",
|
||||
"userinfoUrl": "https://your-provider.com/application/o/userinfo/",
|
||||
"enterUsername": "输入用户名以设为管理员",
|
||||
@@ -1421,9 +1603,9 @@
|
||||
"passwordRequired": "需要输入密码",
|
||||
"failedToDeleteAccount": "删除账户失败",
|
||||
"failedToMakeUserAdmin": "设为管理员失败",
|
||||
"userIsNowAdmin": "用户 {{username}} 现在是管理员",
|
||||
"removeAdminConfirm": "确定要移除 {{username}} 的管理员权限吗?",
|
||||
"deleteUserConfirm": "确定要删除用户 {{username}} 吗?此操作无法撤销。",
|
||||
"userIsNowAdmin": "用户 {{用户名}} 现在是管理员",
|
||||
"removeAdminConfirm": "确定要移除 {{用户名}} 的管理员权限吗?",
|
||||
"deleteUserConfirm": "确定要删除用户 {{用户名}} 吗?此操作无法撤销。",
|
||||
"deleteAccount": "删除账户",
|
||||
"closeDeleteAccount": "关闭删除账户",
|
||||
"deleteAccountWarning": "此操作无法撤销。这将永久删除您的账户和所有相关数据。",
|
||||
@@ -1470,7 +1652,76 @@
|
||||
"failedToStartOidcLogin": "启动 OIDC 登录失败",
|
||||
"failedToGetUserInfoAfterOidc": "OIDC 登录后获取用户信息失败",
|
||||
"loginWithExternalProvider": "使用外部提供者登录",
|
||||
"failedToStartTotpSetup": "启动 TOTP 设置失败"
|
||||
"failedToStartTotpSetup": "启动 TOTP 设置失败",
|
||||
"addHost": "添加 主机",
|
||||
"adding": "添加ing...",
|
||||
"authentication": "认证",
|
||||
"cannotDeleteAccount": "Can没有t 删除 Account",
|
||||
"clickToSelectFile": "Click to 选择 a 文件",
|
||||
"clientId": "Client ID",
|
||||
"clientSecret": "Client Secret",
|
||||
"closeDeleteAccount": "关闭 删除 Account",
|
||||
"configureExternalProvider": "Configure external identity provider for",
|
||||
"confirmPassword": "Confirm 密码",
|
||||
"connected": "已连接",
|
||||
"createNewFile": "创建 New 文件",
|
||||
"createNewFolder": "创建 New 文件夹",
|
||||
"defaultPath": "Default Path",
|
||||
"deleteAccount": "删除 Account",
|
||||
"deleteItem": "删除 Item",
|
||||
"deleting": "删除中...",
|
||||
"disconnected": "已断开",
|
||||
"editHost": "编辑 主机",
|
||||
"enableFileManager": "启用 文件 Manager",
|
||||
"enableTerminal": "启用 终端",
|
||||
"enableTunnel": "启用 隧道",
|
||||
"endpointHostNotFound": "Endpoint host 未找到",
|
||||
"external": "External",
|
||||
"failedToCompletePasswordReset": "无法 完成 密码 reset",
|
||||
"failedToDisableTotp": "无法 禁用 TOTP",
|
||||
"failedToGenerateBackupCodes": "无法 generate 返回up codes",
|
||||
"failedToInitiatePasswordReset": "无法 initiate 密码 reset",
|
||||
"failedToMakeUserAdmin": "无法 make 用户 管理员",
|
||||
"failedToUpdateOidcConfig": "无法 更新 OIDC 配置",
|
||||
"failedToVerifyResetCode": "无法 verify reset code",
|
||||
"invalidTotpCode": "Invalid TOTP code",
|
||||
"invalidVerificationCode": "Invalid verification code",
|
||||
"key": "密钥",
|
||||
"keyPassword": "密钥 密码",
|
||||
"keyType": "密钥 Type",
|
||||
"keyTypeRequired": "密钥 Type 是必需的 when using 密钥 认证",
|
||||
"loading": "加载中...",
|
||||
"local": "Local",
|
||||
"login": "Login",
|
||||
"loginWithExternal": "Login with External Provider",
|
||||
"makeAdmin": "Make 管理员",
|
||||
"maxRetries": "Max Retries",
|
||||
"newFile": "New 文件",
|
||||
"newFolder": "New 文件夹",
|
||||
"password": "密码",
|
||||
"passwordRequired": "密码 是必需的 when using 密码 认证",
|
||||
"refresh": "刷新",
|
||||
"renameItem": "Re名称 Item",
|
||||
"resetPassword": "Reset 密码",
|
||||
"retryingConnection": "重试ing 连接",
|
||||
"saveConfiguration": "保存 配置",
|
||||
"saving": "保存中...",
|
||||
"sendResetCode": "Send Reset Code",
|
||||
"signUp": "Sign Up",
|
||||
"sshHosts": "SSH 主机s",
|
||||
"sshKeyRequired": "SSH Private 密钥 是必需的 when using 密钥 认证",
|
||||
"sshPrivateKey": "SSH Private 密钥",
|
||||
"tunnelConnections": "隧道 连接s",
|
||||
"unknown": "Unk没有wn",
|
||||
"unknownError": "Unk没有wn 错误",
|
||||
"updateHost": "更新 主机",
|
||||
"updateKey": "更新 密钥",
|
||||
"upload": "上传",
|
||||
"user": "用户",
|
||||
"verifyAndEnable": "Verify and 启用",
|
||||
"verifyCode": "Verify Code",
|
||||
"waitingForRetry": "Waiting for 重试",
|
||||
"warning": "警告"
|
||||
},
|
||||
"mobile": {
|
||||
"selectHostToStart": "选择一个主机以开始您的终端会话",
|
||||
@@ -1512,5 +1763,28 @@
|
||||
"cpu": "CPU",
|
||||
"ram": "内存",
|
||||
"notAvailable": "不可用"
|
||||
},
|
||||
"commandPalette": {
|
||||
"searchPlaceholder": "搜索主机或快速操作...",
|
||||
"recentActivity": "最近活动",
|
||||
"navigation": "导航",
|
||||
"addHost": "添加主机",
|
||||
"addCredential": "添加凭据",
|
||||
"adminSettings": "管理员设置",
|
||||
"userProfile": "用户资料",
|
||||
"updateLog": "更新日志",
|
||||
"hosts": "主机",
|
||||
"openServerDetails": "打开服务器详情",
|
||||
"openFileManager": "打开文件管理器",
|
||||
"edit": "编辑",
|
||||
"links": "链接",
|
||||
"github": "GitHub",
|
||||
"support": "支持",
|
||||
"discord": "Discord",
|
||||
"donate": "捐赠",
|
||||
"press": "按下",
|
||||
"toToggle": "来切换",
|
||||
"close": "关闭",
|
||||
"hostManager": "主机管理器"
|
||||
}
|
||||
}
|
||||
}
|
||||
109
src/types/guacamole-common-js.d.ts
vendored
Normal file
109
src/types/guacamole-common-js.d.ts
vendored
Normal file
@@ -0,0 +1,109 @@
|
||||
declare module "guacamole-common-js" {
|
||||
namespace Guacamole {
|
||||
class Client {
|
||||
constructor(tunnel: Tunnel);
|
||||
connect(data?: string): void;
|
||||
disconnect(): void;
|
||||
getDisplay(): Display;
|
||||
sendKeyEvent(pressed: number, keysym: number): void;
|
||||
sendMouseState(state: Mouse.State): void;
|
||||
setClipboard(stream: OutputStream, mimetype: string): void;
|
||||
createClipboardStream(mimetype: string): OutputStream;
|
||||
onstatechange: ((state: number) => void) | null;
|
||||
onerror: ((error: Status) => void) | null;
|
||||
onclipboard: ((stream: InputStream, mimetype: string) => void) | null;
|
||||
}
|
||||
|
||||
class Display {
|
||||
getElement(): HTMLElement;
|
||||
getWidth(): number;
|
||||
getHeight(): number;
|
||||
scale(scale: number): void;
|
||||
onresize: (() => void) | null;
|
||||
}
|
||||
|
||||
class Tunnel {
|
||||
onerror: ((status: Status) => void) | null;
|
||||
onstatechange: ((state: number) => void) | null;
|
||||
}
|
||||
|
||||
class WebSocketTunnel extends Tunnel {
|
||||
constructor(url: string);
|
||||
}
|
||||
|
||||
class Mouse {
|
||||
constructor(element: HTMLElement);
|
||||
onmousedown: ((state: Mouse.State) => void) | null;
|
||||
onmouseup: ((state: Mouse.State) => void) | null;
|
||||
onmousemove: ((state: Mouse.State) => void) | null;
|
||||
onmouseout: ((state: Mouse.State) => void) | null;
|
||||
}
|
||||
|
||||
namespace Mouse {
|
||||
class State {
|
||||
constructor(
|
||||
x: number,
|
||||
y: number,
|
||||
left?: boolean,
|
||||
middle?: boolean,
|
||||
right?: boolean,
|
||||
up?: boolean,
|
||||
down?: boolean
|
||||
);
|
||||
constructor(state: {
|
||||
x: number;
|
||||
y: number;
|
||||
left?: boolean;
|
||||
middle?: boolean;
|
||||
right?: boolean;
|
||||
up?: boolean;
|
||||
down?: boolean;
|
||||
});
|
||||
x: number;
|
||||
y: number;
|
||||
left: boolean;
|
||||
middle: boolean;
|
||||
right: boolean;
|
||||
up: boolean;
|
||||
down: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
class Keyboard {
|
||||
constructor(element: Document | HTMLElement);
|
||||
onkeydown: ((keysym: number) => void) | null;
|
||||
onkeyup: ((keysym: number) => void) | null;
|
||||
}
|
||||
|
||||
class Status {
|
||||
code: number;
|
||||
message: string;
|
||||
isError(): boolean;
|
||||
}
|
||||
|
||||
class InputStream {
|
||||
onblob: ((data: string) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
}
|
||||
|
||||
class OutputStream {
|
||||
sendBlob(data: string): void;
|
||||
sendEnd(): void;
|
||||
}
|
||||
|
||||
class StringReader {
|
||||
constructor(stream: InputStream);
|
||||
ontext: ((text: string) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
}
|
||||
|
||||
class StringWriter {
|
||||
constructor(stream: OutputStream);
|
||||
sendText(text: string): void;
|
||||
sendEnd(): void;
|
||||
}
|
||||
}
|
||||
|
||||
export default Guacamole;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,122 @@ import type { Request } from "express";
|
||||
// SSH HOST TYPES
|
||||
// ============================================================================
|
||||
|
||||
export interface JumpHost {
|
||||
hostId: number;
|
||||
}
|
||||
|
||||
export interface QuickAction {
|
||||
name: string;
|
||||
snippetId: number;
|
||||
}
|
||||
|
||||
export interface DockerConfig {
|
||||
connectionType: "socket" | "tcp" | "tls";
|
||||
socketPath?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
tlsVerify?: boolean;
|
||||
tlsCaCert?: string;
|
||||
tlsCert?: string;
|
||||
tlsKey?: string;
|
||||
}
|
||||
|
||||
export type HostConnectionType = "ssh" | "rdp" | "vnc" | "telnet";
|
||||
|
||||
// Guacamole configuration for RDP/VNC/Telnet connections
|
||||
export interface GuacamoleConfig {
|
||||
// Display settings
|
||||
colorDepth?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
dpi?: number;
|
||||
resizeMethod?: string;
|
||||
forceLossless?: boolean;
|
||||
// Audio settings
|
||||
disableAudio?: boolean;
|
||||
enableAudioInput?: boolean;
|
||||
// RDP Performance settings
|
||||
enableWallpaper?: boolean;
|
||||
enableTheming?: boolean;
|
||||
enableFontSmoothing?: boolean;
|
||||
enableFullWindowDrag?: boolean;
|
||||
enableDesktopComposition?: boolean;
|
||||
enableMenuAnimations?: boolean;
|
||||
disableBitmapCaching?: boolean;
|
||||
disableOffscreenCaching?: boolean;
|
||||
disableGlyphCaching?: boolean;
|
||||
disableGfx?: boolean;
|
||||
// RDP Device redirection
|
||||
enablePrinting?: boolean;
|
||||
printerName?: string;
|
||||
enableDrive?: boolean;
|
||||
driveName?: string;
|
||||
drivePath?: string;
|
||||
createDrivePath?: boolean;
|
||||
disableDownload?: boolean;
|
||||
disableUpload?: boolean;
|
||||
enableTouch?: boolean;
|
||||
// RDP Session settings
|
||||
clientName?: string;
|
||||
console?: boolean;
|
||||
initialProgram?: string;
|
||||
serverLayout?: string;
|
||||
timezone?: string;
|
||||
// RDP Gateway settings
|
||||
gatewayHostname?: string;
|
||||
gatewayPort?: number;
|
||||
gatewayUsername?: string;
|
||||
gatewayPassword?: string;
|
||||
gatewayDomain?: string;
|
||||
// RDP RemoteApp settings
|
||||
remoteApp?: string;
|
||||
remoteAppDir?: string;
|
||||
remoteAppArgs?: string;
|
||||
// RDP Preconnection settings (Hyper-V)
|
||||
preconnectionId?: number;
|
||||
preconnectionBlob?: string;
|
||||
// RDP Load balancing
|
||||
loadBalanceInfo?: string;
|
||||
// Clipboard settings
|
||||
normalizeClipboard?: string;
|
||||
disableCopy?: boolean;
|
||||
disablePaste?: boolean;
|
||||
// VNC specific settings
|
||||
cursor?: string;
|
||||
swapRedBlue?: boolean;
|
||||
readOnly?: boolean;
|
||||
// VNC Repeater settings
|
||||
destHost?: string;
|
||||
destPort?: number;
|
||||
// VNC Reverse connection
|
||||
reverseConnect?: boolean;
|
||||
listenTimeout?: number;
|
||||
// Common SFTP settings (for RDP/VNC file transfer)
|
||||
enableSftp?: boolean;
|
||||
sftpHostname?: string;
|
||||
sftpPort?: number;
|
||||
sftpUsername?: string;
|
||||
sftpPassword?: string;
|
||||
sftpPrivateKey?: string;
|
||||
sftpDirectory?: string;
|
||||
// Recording settings
|
||||
recordingPath?: string;
|
||||
recordingName?: string;
|
||||
createRecordingPath?: boolean;
|
||||
recordingExcludeOutput?: boolean;
|
||||
recordingExcludeMouse?: boolean;
|
||||
recordingIncludeKeys?: boolean;
|
||||
// Wake-on-LAN settings
|
||||
wolSendPacket?: boolean;
|
||||
wolMacAddr?: string;
|
||||
wolBroadcastAddr?: string;
|
||||
wolUdpPort?: number;
|
||||
wolWaitTime?: number;
|
||||
}
|
||||
|
||||
export interface SSHHost {
|
||||
id: number;
|
||||
connectionType: HostConnectionType;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
@@ -26,19 +140,40 @@ export interface SSHHost {
|
||||
autostartKeyPassword?: string;
|
||||
|
||||
credentialId?: number;
|
||||
overrideCredentialUsername?: boolean;
|
||||
userId?: string;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableFileManager: boolean;
|
||||
enableDocker: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: TunnelConnection[];
|
||||
jumpHosts?: JumpHost[];
|
||||
quickActions?: QuickAction[];
|
||||
statsConfig?: string;
|
||||
dockerConfig?: string;
|
||||
terminalConfig?: TerminalConfig;
|
||||
// RDP/VNC specific fields (basic)
|
||||
domain?: string;
|
||||
security?: string;
|
||||
ignoreCert?: boolean;
|
||||
// RDP/VNC extended configuration (stored as JSON)
|
||||
guacamoleConfig?: GuacamoleConfig | string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface JumpHostData {
|
||||
hostId: number;
|
||||
}
|
||||
|
||||
export interface QuickActionData {
|
||||
name: string;
|
||||
snippetId: number;
|
||||
}
|
||||
|
||||
export interface SSHHostData {
|
||||
connectionType?: HostConnectionType;
|
||||
name?: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
@@ -52,14 +187,35 @@ export interface SSHHostData {
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
credentialId?: number | null;
|
||||
overrideCredentialUsername?: boolean;
|
||||
enableTerminal?: boolean;
|
||||
enableTunnel?: boolean;
|
||||
enableFileManager?: boolean;
|
||||
enableDocker?: boolean;
|
||||
defaultPath?: string;
|
||||
forceKeyboardInteractive?: boolean;
|
||||
tunnelConnections?: TunnelConnection[];
|
||||
jumpHosts?: JumpHostData[];
|
||||
quickActions?: QuickActionData[];
|
||||
statsConfig?: string | Record<string, unknown>;
|
||||
dockerConfig?: DockerConfig | string;
|
||||
terminalConfig?: TerminalConfig;
|
||||
// RDP/VNC specific fields (basic)
|
||||
domain?: string;
|
||||
security?: string;
|
||||
ignoreCert?: boolean;
|
||||
// RDP/VNC extended configuration
|
||||
guacamoleConfig?: GuacamoleConfig;
|
||||
}
|
||||
|
||||
export interface SSHFolder {
|
||||
id: number;
|
||||
userId: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -85,6 +241,28 @@ export interface Credential {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CredentialBackend {
|
||||
id: number;
|
||||
userId: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
folder: string | null;
|
||||
tags: string;
|
||||
authType: "password" | "key";
|
||||
username: string;
|
||||
password: string | null;
|
||||
key: string;
|
||||
private_key?: string;
|
||||
public_key?: string;
|
||||
key_password: string | null;
|
||||
keyType?: string;
|
||||
detectedKeyType: string;
|
||||
usageCount: number;
|
||||
lastUsed: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CredentialData {
|
||||
name: string;
|
||||
description?: string;
|
||||
@@ -273,6 +451,7 @@ export interface TerminalConfig {
|
||||
startupSnippetId: number | null;
|
||||
autoMosh: boolean;
|
||||
moshCommand: string;
|
||||
sudoPasswordAutoFill: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -288,11 +467,45 @@ export interface TabContextTab {
|
||||
| "server"
|
||||
| "admin"
|
||||
| "file_manager"
|
||||
| "user_profile";
|
||||
| "user_profile"
|
||||
| "rdp"
|
||||
| "vnc"
|
||||
| "tunnel"
|
||||
| "docker";
|
||||
title: string;
|
||||
hostConfig?: SSHHost;
|
||||
terminalRef?: any;
|
||||
initialTab?: string;
|
||||
connectionConfig?: {
|
||||
token: string;
|
||||
protocol: "rdp" | "vnc" | "telnet";
|
||||
type?: "rdp" | "vnc" | "telnet";
|
||||
hostname?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
domain?: string;
|
||||
security?: string;
|
||||
"ignore-cert"?: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
dpi?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type SplitLayout = "2h" | "2v" | "3l" | "3r" | "3t" | "4grid";
|
||||
|
||||
export interface SplitConfiguration {
|
||||
layout: SplitLayout;
|
||||
positions: Map<number, number>;
|
||||
}
|
||||
|
||||
export interface SplitLayoutOption {
|
||||
id: SplitLayout;
|
||||
name: string;
|
||||
description: string;
|
||||
cellCount: number;
|
||||
icon: string; // lucide icon name
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -369,6 +582,8 @@ export interface HostManagerProps {
|
||||
isTopbarOpen?: boolean;
|
||||
initialTab?: string;
|
||||
hostConfig?: SSHHost;
|
||||
rightSidebarOpen?: boolean;
|
||||
rightSidebarWidth?: number;
|
||||
}
|
||||
|
||||
export interface SSHManagerHostEditorProps {
|
||||
@@ -455,6 +670,8 @@ export interface Snippet {
|
||||
name: string;
|
||||
content: string;
|
||||
description?: string;
|
||||
folder?: string;
|
||||
order?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -463,6 +680,18 @@ export interface SnippetData {
|
||||
name: string;
|
||||
content: string;
|
||||
description?: string;
|
||||
folder?: string;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface SnippetFolder {
|
||||
id: number;
|
||||
userId: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -5,7 +5,8 @@ export type WidgetType =
|
||||
| "network"
|
||||
| "uptime"
|
||||
| "processes"
|
||||
| "system";
|
||||
| "system"
|
||||
| "login_stats";
|
||||
|
||||
export interface StatsConfig {
|
||||
enabledWidgets: WidgetType[];
|
||||
@@ -16,7 +17,15 @@ export interface StatsConfig {
|
||||
}
|
||||
|
||||
export const DEFAULT_STATS_CONFIG: StatsConfig = {
|
||||
enabledWidgets: ["cpu", "memory", "disk", "network", "uptime", "system"],
|
||||
enabledWidgets: [
|
||||
"cpu",
|
||||
"memory",
|
||||
"disk",
|
||||
"network",
|
||||
"uptime",
|
||||
"system",
|
||||
"login_stats",
|
||||
],
|
||||
statusCheckEnabled: true,
|
||||
statusCheckInterval: 30,
|
||||
metricsEnabled: true,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { LeftSidebar } from "@/ui/desktop/navigation/LeftSidebar.tsx";
|
||||
import { Dashboard } from "@/ui/desktop/apps/dashboard/Dashboard.tsx";
|
||||
import { AppView } from "@/ui/desktop/navigation/AppView.tsx";
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
useTabs,
|
||||
} from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
import { TopNavbar } from "@/ui/desktop/navigation/TopNavbar.tsx";
|
||||
import { CommandHistoryProvider } from "@/ui/desktop/apps/terminal/command-history/CommandHistoryContext.tsx";
|
||||
import { AdminSettings } from "@/ui/desktop/admin/AdminSettings.tsx";
|
||||
import { UserProfile } from "@/ui/desktop/user/UserProfile.tsx";
|
||||
import { Toaster } from "@/components/ui/sonner.tsx";
|
||||
import { CommandPalette } from "@/ui/desktop/apps/command-palette/CommandPalette.tsx";
|
||||
import { getUserInfo } from "@/ui/main-axios.ts";
|
||||
|
||||
function AppContent() {
|
||||
@@ -22,7 +24,41 @@ function AppContent() {
|
||||
const saved = localStorage.getItem("topNavbarOpen");
|
||||
return saved !== null ? JSON.parse(saved) : true;
|
||||
});
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
const [transitionPhase, setTransitionPhase] = useState<
|
||||
"idle" | "fadeOut" | "fadeIn"
|
||||
>("idle");
|
||||
const { currentTab, tabs } = useTabs();
|
||||
const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
|
||||
const [rightSidebarOpen, setRightSidebarOpen] = useState(false);
|
||||
const [rightSidebarWidth, setRightSidebarWidth] = useState(400);
|
||||
|
||||
const lastShiftPressTime = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.code === "ShiftLeft") {
|
||||
if (event.repeat) {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (now - lastShiftPressTime.current < 300) {
|
||||
setIsCommandPaletteOpen((isOpen) => !isOpen);
|
||||
lastShiftPressTime.current = 0;
|
||||
} else {
|
||||
lastShiftPressTime.current = now;
|
||||
}
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
setIsCommandPaletteOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = () => {
|
||||
@@ -33,6 +69,7 @@ function AppContent() {
|
||||
setIsAuthenticated(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
localStorage.removeItem("jwt");
|
||||
} else {
|
||||
setIsAuthenticated(true);
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
@@ -44,6 +81,8 @@ function AppContent() {
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
|
||||
localStorage.removeItem("jwt");
|
||||
|
||||
const errorCode = err?.response?.data?.code;
|
||||
if (errorCode === "SESSION_EXPIRED") {
|
||||
console.warn("Session expired - please log in again");
|
||||
@@ -74,27 +113,87 @@ function AppContent() {
|
||||
username: string | null;
|
||||
userId: string | null;
|
||||
}) => {
|
||||
setIsAuthenticated(true);
|
||||
setIsAdmin(authData.isAdmin);
|
||||
setUsername(authData.username);
|
||||
setIsTransitioning(true);
|
||||
setTransitionPhase("fadeOut");
|
||||
|
||||
setTimeout(() => {
|
||||
setIsAuthenticated(true);
|
||||
setIsAdmin(authData.isAdmin);
|
||||
setUsername(authData.username);
|
||||
setTransitionPhase("fadeIn");
|
||||
|
||||
setTimeout(() => {
|
||||
setIsTransitioning(false);
|
||||
setTransitionPhase("idle");
|
||||
}, 800);
|
||||
}, 1200);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleLogout = useCallback(async () => {
|
||||
setIsTransitioning(true);
|
||||
setTransitionPhase("fadeOut");
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const { logoutUser, isElectron } = await import("@/ui/main-axios.ts");
|
||||
await logoutUser();
|
||||
|
||||
if (isElectron()) {
|
||||
localStorage.removeItem("jwt");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Logout failed:", error);
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
}, 1200);
|
||||
}, []);
|
||||
|
||||
const currentTabData = tabs.find((tab) => tab.id === currentTab);
|
||||
const showTerminalView =
|
||||
currentTabData?.type === "terminal" ||
|
||||
currentTabData?.type === "server" ||
|
||||
currentTabData?.type === "file_manager";
|
||||
currentTabData?.type === "file_manager" ||
|
||||
currentTabData?.type === "rdp" ||
|
||||
currentTabData?.type === "vnc" ||
|
||||
currentTabData?.type === "tunnel" ||
|
||||
currentTabData?.type === "docker";
|
||||
const showHome = currentTabData?.type === "home";
|
||||
const showSshManager = currentTabData?.type === "ssh_manager";
|
||||
const showAdmin = currentTabData?.type === "admin";
|
||||
const showProfile = currentTabData?.type === "user_profile";
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div
|
||||
className="h-screen w-screen flex items-center justify-center bg-dark-bg-darkest"
|
||||
style={{
|
||||
backgroundImage: `repeating-linear-gradient(
|
||||
225deg,
|
||||
transparent,
|
||||
transparent 35px,
|
||||
rgba(255, 255, 255, 0.03) 35px,
|
||||
rgba(255, 255, 255, 0.03) 37px
|
||||
)`,
|
||||
}}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 border-4 border-primary/30 border-t-primary rounded-full animate-spin mx-auto" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!isAuthenticated && !authLoading && (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[10000]">
|
||||
<div className="h-screen w-screen overflow-hidden bg-background">
|
||||
<CommandPalette
|
||||
isOpen={isCommandPaletteOpen}
|
||||
setIsOpen={setIsCommandPaletteOpen}
|
||||
/>
|
||||
{!isAuthenticated && (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[10000] bg-background">
|
||||
<Dashboard
|
||||
onSelectView={handleSelectView}
|
||||
isAuthenticated={isAuthenticated}
|
||||
@@ -111,12 +210,17 @@ function AppContent() {
|
||||
disabled={!isAuthenticated || authLoading}
|
||||
isAdmin={isAdmin}
|
||||
username={username}
|
||||
onLogout={handleLogout}
|
||||
>
|
||||
<div
|
||||
className="h-screen w-full visible pointer-events-auto static overflow-hidden"
|
||||
style={{ display: showTerminalView ? "block" : "none" }}
|
||||
>
|
||||
<AppView isTopbarOpen={isTopbarOpen} />
|
||||
<AppView
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
rightSidebarOpen={rightSidebarOpen}
|
||||
rightSidebarWidth={rightSidebarWidth}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showHome && (
|
||||
@@ -127,6 +231,8 @@ function AppContent() {
|
||||
authLoading={authLoading}
|
||||
onAuthSuccess={handleAuthSuccess}
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
rightSidebarOpen={rightSidebarOpen}
|
||||
rightSidebarWidth={rightSidebarWidth}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -138,28 +244,210 @@ function AppContent() {
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
initialTab={currentTabData?.initialTab}
|
||||
hostConfig={currentTabData?.hostConfig}
|
||||
rightSidebarOpen={rightSidebarOpen}
|
||||
rightSidebarWidth={rightSidebarWidth}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdmin && (
|
||||
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
|
||||
<AdminSettings isTopbarOpen={isTopbarOpen} />
|
||||
<AdminSettings
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
rightSidebarOpen={rightSidebarOpen}
|
||||
rightSidebarWidth={rightSidebarWidth}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showProfile && (
|
||||
<div className="h-screen w-full visible pointer-events-auto static overflow-auto">
|
||||
<UserProfile isTopbarOpen={isTopbarOpen} />
|
||||
<UserProfile
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
rightSidebarOpen={rightSidebarOpen}
|
||||
rightSidebarWidth={rightSidebarWidth}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TopNavbar
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
setIsTopbarOpen={setIsTopbarOpen}
|
||||
onOpenCommandPalette={() => setIsCommandPaletteOpen(true)}
|
||||
onRightSidebarStateChange={(isOpen, width) => {
|
||||
setRightSidebarOpen(isOpen);
|
||||
setRightSidebarWidth(width);
|
||||
}}
|
||||
/>
|
||||
</LeftSidebar>
|
||||
)}
|
||||
|
||||
{isTransitioning && (
|
||||
<div
|
||||
className={`fixed inset-0 bg-background z-[20000] transition-opacity duration-700 ${
|
||||
transitionPhase === "fadeOut" ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
{transitionPhase === "fadeOut" && (
|
||||
<>
|
||||
<div className="absolute inset-0 flex items-center justify-center overflow-hidden">
|
||||
<div
|
||||
className="absolute w-0 h-0 bg-primary/10 rounded-full"
|
||||
style={{
|
||||
animation:
|
||||
"ripple 2.5s cubic-bezier(0.4, 0, 0.2, 1) forwards",
|
||||
animationDelay: "0ms",
|
||||
willChange: "width, height, opacity",
|
||||
transform: "translateZ(0)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute w-0 h-0 bg-primary/7 rounded-full"
|
||||
style={{
|
||||
animation:
|
||||
"ripple 2.5s cubic-bezier(0.4, 0, 0.2, 1) forwards",
|
||||
animationDelay: "200ms",
|
||||
willChange: "width, height, opacity",
|
||||
transform: "translateZ(0)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute w-0 h-0 bg-primary/5 rounded-full"
|
||||
style={{
|
||||
animation:
|
||||
"ripple 2.5s cubic-bezier(0.4, 0, 0.2, 1) forwards",
|
||||
animationDelay: "400ms",
|
||||
willChange: "width, height, opacity",
|
||||
transform: "translateZ(0)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute w-0 h-0 bg-primary/3 rounded-full"
|
||||
style={{
|
||||
animation:
|
||||
"ripple 2.5s cubic-bezier(0.4, 0, 0.2, 1) forwards",
|
||||
animationDelay: "600ms",
|
||||
willChange: "width, height, opacity",
|
||||
transform: "translateZ(0)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="relative z-10 text-center"
|
||||
style={{
|
||||
animation:
|
||||
"logoFade 1.6s cubic-bezier(0.4, 0, 0.2, 1) forwards",
|
||||
willChange: "opacity, transform",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="text-7xl font-bold tracking-wider"
|
||||
style={{
|
||||
fontFamily:
|
||||
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||
animation:
|
||||
"logoGlow 1.6s cubic-bezier(0.4, 0, 0.2, 1) forwards",
|
||||
willChange: "color, text-shadow",
|
||||
}}
|
||||
>
|
||||
TERMIX
|
||||
</div>
|
||||
<div
|
||||
className="text-sm text-muted-foreground mt-3 tracking-widest"
|
||||
style={{
|
||||
animation:
|
||||
"subtitleFade 1.6s cubic-bezier(0.4, 0, 0.2, 1) forwards",
|
||||
willChange: "opacity, transform",
|
||||
}}
|
||||
>
|
||||
SSH SERVER MANAGER
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes ripple {
|
||||
0% {
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
30% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
70% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
100% {
|
||||
width: 200vmax;
|
||||
height: 200vmax;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes logoFade {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.85) translateZ(0);
|
||||
}
|
||||
25% {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateZ(0);
|
||||
}
|
||||
75% {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateZ(0);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(1.05) translateZ(0);
|
||||
}
|
||||
}
|
||||
@keyframes logoGlow {
|
||||
0% {
|
||||
color: hsl(var(--primary));
|
||||
text-shadow: none;
|
||||
}
|
||||
25% {
|
||||
color: hsl(var(--primary));
|
||||
text-shadow:
|
||||
0 0 20px hsla(var(--primary), 0.3),
|
||||
0 0 40px hsla(var(--primary), 0.2),
|
||||
0 0 60px hsla(var(--primary), 0.1);
|
||||
}
|
||||
75% {
|
||||
color: hsl(var(--primary));
|
||||
text-shadow:
|
||||
0 0 20px hsla(var(--primary), 0.3),
|
||||
0 0 40px hsla(var(--primary), 0.2),
|
||||
0 0 60px hsla(var(--primary), 0.1);
|
||||
}
|
||||
100% {
|
||||
color: hsl(var(--primary));
|
||||
text-shadow: none;
|
||||
}
|
||||
}
|
||||
@keyframes subtitleFade {
|
||||
0%, 30% {
|
||||
opacity: 0;
|
||||
transform: translateY(10px) translateZ(0);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) translateZ(0);
|
||||
}
|
||||
75% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) translateZ(0);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-5px) translateZ(0);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
richColors={false}
|
||||
@@ -174,7 +462,9 @@ function AppContent() {
|
||||
function DesktopApp() {
|
||||
return (
|
||||
<TabProvider>
|
||||
<AppContent />
|
||||
<CommandHistoryProvider>
|
||||
<AppContent />
|
||||
</CommandHistoryProvider>
|
||||
</TabProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,14 @@ import {
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog.tsx";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -26,7 +34,8 @@ import {
|
||||
Trash2,
|
||||
Users,
|
||||
Database,
|
||||
Lock,
|
||||
Link2,
|
||||
Unlink,
|
||||
Download,
|
||||
Upload,
|
||||
Monitor,
|
||||
@@ -55,14 +64,20 @@ import {
|
||||
getSessions,
|
||||
revokeSession,
|
||||
revokeAllUserSessions,
|
||||
linkOIDCToPasswordAccount,
|
||||
unlinkOIDCFromPasswordAccount,
|
||||
} from "@/ui/main-axios.ts";
|
||||
|
||||
interface AdminSettingsProps {
|
||||
isTopbarOpen?: boolean;
|
||||
rightSidebarOpen?: boolean;
|
||||
rightSidebarWidth?: number;
|
||||
}
|
||||
|
||||
export function AdminSettings({
|
||||
isTopbarOpen = true,
|
||||
rightSidebarOpen = false,
|
||||
rightSidebarWidth = 400,
|
||||
}: AdminSettingsProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
@@ -94,6 +109,7 @@ export function AdminSettings({
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
is_oidc: boolean;
|
||||
password_hash?: string;
|
||||
}>
|
||||
>([]);
|
||||
const [usersLoading, setUsersLoading] = React.useState(false);
|
||||
@@ -134,6 +150,14 @@ export function AdminSettings({
|
||||
>([]);
|
||||
const [sessionsLoading, setSessionsLoading] = React.useState(false);
|
||||
|
||||
const [linkAccountAlertOpen, setLinkAccountAlertOpen] = React.useState(false);
|
||||
const [linkOidcUser, setLinkOidcUser] = React.useState<{
|
||||
id: string;
|
||||
username: string;
|
||||
} | null>(null);
|
||||
const [linkTargetUsername, setLinkTargetUsername] = React.useState("");
|
||||
const [linkLoading, setLinkLoading] = React.useState(false);
|
||||
|
||||
const requiresImportPassword = React.useMemo(
|
||||
() => !currentUser?.is_oidc,
|
||||
[currentUser?.is_oidc],
|
||||
@@ -632,15 +656,82 @@ export function AdminSettings({
|
||||
);
|
||||
};
|
||||
|
||||
const handleLinkOIDCUser = (user: { id: string; username: string }) => {
|
||||
setLinkOidcUser(user);
|
||||
setLinkTargetUsername("");
|
||||
setLinkAccountAlertOpen(true);
|
||||
};
|
||||
|
||||
const handleLinkSubmit = async () => {
|
||||
if (!linkOidcUser || !linkTargetUsername.trim()) {
|
||||
toast.error("Target username is required");
|
||||
return;
|
||||
}
|
||||
|
||||
setLinkLoading(true);
|
||||
try {
|
||||
const result = await linkOIDCToPasswordAccount(
|
||||
linkOidcUser.id,
|
||||
linkTargetUsername.trim(),
|
||||
);
|
||||
|
||||
toast.success(
|
||||
result.message ||
|
||||
`OIDC user ${linkOidcUser.username} linked to ${linkTargetUsername}`,
|
||||
);
|
||||
setLinkAccountAlertOpen(false);
|
||||
setLinkTargetUsername("");
|
||||
setLinkOidcUser(null);
|
||||
fetchUsers();
|
||||
fetchSessions();
|
||||
} catch (error: unknown) {
|
||||
const err = error as {
|
||||
response?: { data?: { error?: string; code?: string } };
|
||||
};
|
||||
toast.error(err.response?.data?.error || "Failed to link accounts");
|
||||
} finally {
|
||||
setLinkLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnlinkOIDC = async (userId: string, username: string) => {
|
||||
confirmWithToast(
|
||||
t("admin.unlinkOIDCDescription", { username }),
|
||||
async () => {
|
||||
try {
|
||||
const result = await unlinkOIDCFromPasswordAccount(userId);
|
||||
|
||||
toast.success(
|
||||
result.message || t("admin.unlinkOIDCSuccess", { username }),
|
||||
);
|
||||
fetchUsers();
|
||||
fetchSessions();
|
||||
} catch (error: unknown) {
|
||||
const err = error as {
|
||||
response?: { data?: { error?: string; code?: string } };
|
||||
};
|
||||
toast.error(
|
||||
err.response?.data?.error || t("admin.failedToUnlinkOIDC"),
|
||||
);
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
};
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
|
||||
const bottomMarginPx = 8;
|
||||
const wrapperStyle: React.CSSProperties = {
|
||||
marginLeft: leftMarginPx,
|
||||
marginRight: 17,
|
||||
marginRight: rightSidebarOpen
|
||||
? `calc(var(--right-sidebar-width, ${rightSidebarWidth}px) + 8px)`
|
||||
: 17,
|
||||
marginTop: topMarginPx,
|
||||
marginBottom: bottomMarginPx,
|
||||
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
|
||||
transition:
|
||||
"margin-left 200ms linear, margin-right 200ms linear, margin-top 200ms linear",
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -1017,20 +1108,55 @@ export function AdminSettings({
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
{user.is_oidc
|
||||
? t("admin.external")
|
||||
: t("admin.local")}
|
||||
{user.is_oidc && user.password_hash
|
||||
? "Dual Auth"
|
||||
: user.is_oidc
|
||||
? t("admin.external")
|
||||
: t("admin.local")}
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteUser(user.username)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
disabled={user.is_admin}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
{user.is_oidc && !user.password_hash && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleLinkOIDCUser({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
})
|
||||
}
|
||||
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
|
||||
title="Link to password account"
|
||||
>
|
||||
<Link2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{user.is_oidc && user.password_hash && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleUnlinkOIDC(user.id, user.username)
|
||||
}
|
||||
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
|
||||
title="Unlink OIDC (keep password only)"
|
||||
>
|
||||
<Unlink className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleDeleteUser(user.username)
|
||||
}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
disabled={user.is_admin}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
@@ -1064,109 +1190,113 @@ export function AdminSettings({
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="px-4">Device</TableHead>
|
||||
<TableHead className="px-4">User</TableHead>
|
||||
<TableHead className="px-4">Created</TableHead>
|
||||
<TableHead className="px-4">Last Active</TableHead>
|
||||
<TableHead className="px-4">Expires</TableHead>
|
||||
<TableHead className="px-4">
|
||||
{t("admin.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sessions.map((session) => {
|
||||
const DeviceIcon =
|
||||
session.deviceType === "desktop"
|
||||
? Monitor
|
||||
: session.deviceType === "mobile"
|
||||
? Smartphone
|
||||
: Globe;
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="px-4">Device</TableHead>
|
||||
<TableHead className="px-4">User</TableHead>
|
||||
<TableHead className="px-4">Created</TableHead>
|
||||
<TableHead className="px-4">Last Active</TableHead>
|
||||
<TableHead className="px-4">Expires</TableHead>
|
||||
<TableHead className="px-4">
|
||||
{t("admin.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sessions.map((session) => {
|
||||
const DeviceIcon =
|
||||
session.deviceType === "desktop"
|
||||
? Monitor
|
||||
: session.deviceType === "mobile"
|
||||
? Smartphone
|
||||
: Globe;
|
||||
|
||||
const createdDate = new Date(session.createdAt);
|
||||
const lastActiveDate = new Date(session.lastActiveAt);
|
||||
const expiresDate = new Date(session.expiresAt);
|
||||
const createdDate = new Date(session.createdAt);
|
||||
const lastActiveDate = new Date(
|
||||
session.lastActiveAt,
|
||||
);
|
||||
const expiresDate = new Date(session.expiresAt);
|
||||
|
||||
const formatDate = (date: Date) =>
|
||||
date.toLocaleDateString() +
|
||||
" " +
|
||||
date.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
const formatDate = (date: Date) =>
|
||||
date.toLocaleDateString() +
|
||||
" " +
|
||||
date.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={session.id}
|
||||
className={
|
||||
session.isRevoked ? "opacity-50" : undefined
|
||||
}
|
||||
>
|
||||
<TableCell className="px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<DeviceIcon className="h-4 w-4" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-sm">
|
||||
{session.deviceInfo}
|
||||
</span>
|
||||
{session.isRevoked && (
|
||||
<span className="text-xs text-red-600">
|
||||
Revoked
|
||||
return (
|
||||
<TableRow
|
||||
key={session.id}
|
||||
className={
|
||||
session.isRevoked ? "opacity-50" : undefined
|
||||
}
|
||||
>
|
||||
<TableCell className="px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<DeviceIcon className="h-4 w-4" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-sm">
|
||||
{session.deviceInfo}
|
||||
</span>
|
||||
)}
|
||||
{session.isRevoked && (
|
||||
<span className="text-xs text-red-600">
|
||||
Revoked
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
{session.username || session.userId}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-sm text-muted-foreground">
|
||||
{formatDate(createdDate)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-sm text-muted-foreground">
|
||||
{formatDate(lastActiveDate)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-sm text-muted-foreground">
|
||||
{formatDate(expiresDate)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleRevokeSession(session.id)
|
||||
}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
disabled={session.isRevoked}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{session.username && (
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
{session.username || session.userId}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-sm text-muted-foreground">
|
||||
{formatDate(createdDate)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-sm text-muted-foreground">
|
||||
{formatDate(lastActiveDate)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-sm text-muted-foreground">
|
||||
{formatDate(expiresDate)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleRevokeAllUserSessions(
|
||||
session.userId,
|
||||
)
|
||||
handleRevokeSession(session.id)
|
||||
}
|
||||
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50 text-xs"
|
||||
title="Revoke all sessions for this user"
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
disabled={session.isRevoked}
|
||||
>
|
||||
Revoke All
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{session.username && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleRevokeAllUserSessions(
|
||||
session.userId,
|
||||
)
|
||||
}
|
||||
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50 text-xs"
|
||||
title="Revoke all sessions for this user"
|
||||
>
|
||||
Revoke All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1177,8 +1307,8 @@ export function AdminSettings({
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("admin.adminManagement")}
|
||||
</h3>
|
||||
<div className="space-y-4 p-6 border rounded-md bg-muted/50">
|
||||
<h4 className="font-medium">{t("admin.makeUserAdmin")}</h4>
|
||||
<div className="space-y-4 p-4 border rounded-md bg-dark-bg-panel">
|
||||
<h4 className="font-semibold">{t("admin.makeUserAdmin")}</h4>
|
||||
<form onSubmit={handleMakeUserAdmin} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-admin-username">
|
||||
@@ -1271,32 +1401,17 @@ export function AdminSettings({
|
||||
<TabsContent value="security" className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Database className="h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("admin.databaseSecurity")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border rounded bg-card">
|
||||
<div className="flex items-center gap-2">
|
||||
<Lock className="h-4 w-4 text-green-500" />
|
||||
<div>
|
||||
<div className="text-sm font-medium">
|
||||
{t("admin.encryptionStatus")}
|
||||
</div>
|
||||
<div className="text-xs text-green-500">
|
||||
{t("admin.encryptionEnabled")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="p-4 border rounded bg-card">
|
||||
<div className="p-4 border rounded-lg bg-dark-bg-panel">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Download className="h-4 w-4 text-blue-500" />
|
||||
<h4 className="font-medium">{t("admin.export")}</h4>
|
||||
<h4 className="font-semibold">{t("admin.export")}</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("admin.exportDescription")}
|
||||
@@ -1343,11 +1458,11 @@ export function AdminSettings({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border rounded bg-card">
|
||||
<div className="p-4 border rounded-lg bg-dark-bg-panel">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Upload className="h-4 w-4 text-green-500" />
|
||||
<h4 className="font-medium">{t("admin.import")}</h4>
|
||||
<h4 className="font-semibold">{t("admin.import")}</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("admin.importDescription")}
|
||||
@@ -1417,6 +1532,87 @@ export function AdminSettings({
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{linkAccountAlertOpen && (
|
||||
<Dialog
|
||||
open={linkAccountAlertOpen}
|
||||
onOpenChange={setLinkAccountAlertOpen}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[500px] bg-dark-bg border-2 border-dark-border">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Link2 className="w-5 h-5" />
|
||||
Link OIDC Account to Password Account
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
Link{" "}
|
||||
<span className="font-mono text-foreground">
|
||||
{linkOidcUser?.username}
|
||||
</span>{" "}
|
||||
(OIDC user) to an existing password account. This will enable
|
||||
dual authentication for the password account.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Warning: OIDC User Data Will Be Deleted</AlertTitle>
|
||||
<AlertDescription>
|
||||
This action will:
|
||||
<ul className="list-disc list-inside mt-2 space-y-1">
|
||||
<li>Delete the OIDC user account and all their data</li>
|
||||
<li>
|
||||
Add OIDC login capability to the target password account
|
||||
</li>
|
||||
<li>
|
||||
Allow the password account to login with both password and
|
||||
OIDC
|
||||
</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="link-target-username"
|
||||
className="text-base font-semibold text-foreground"
|
||||
>
|
||||
Target Password Account Username
|
||||
</Label>
|
||||
<Input
|
||||
id="link-target-username"
|
||||
value={linkTargetUsername}
|
||||
onChange={(e) => setLinkTargetUsername(e.target.value)}
|
||||
placeholder="Enter username of password account"
|
||||
disabled={linkLoading}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && linkTargetUsername.trim()) {
|
||||
handleLinkSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setLinkAccountAlertOpen(false)}
|
||||
disabled={linkLoading}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleLinkSubmit}
|
||||
disabled={linkLoading || !linkTargetUsername.trim()}
|
||||
variant="destructive"
|
||||
>
|
||||
{linkLoading ? "Linking..." : "Link Accounts"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
420
src/ui/desktop/apps/command-palette/CommandPalette.tsx
Normal file
420
src/ui/desktop/apps/command-palette/CommandPalette.tsx
Normal file
@@ -0,0 +1,420 @@
|
||||
import {
|
||||
Command,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandGroup,
|
||||
CommandSeparator,
|
||||
} from "@/components/ui/command.tsx";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Kbd, KbdGroup } from "@/components/ui/kbd";
|
||||
import {
|
||||
Key,
|
||||
Server,
|
||||
Settings,
|
||||
User,
|
||||
Github,
|
||||
Terminal,
|
||||
FolderOpen,
|
||||
Pencil,
|
||||
EllipsisVertical,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BiMoney, BiSupport } from "react-icons/bi";
|
||||
import { BsDiscord } from "react-icons/bs";
|
||||
import { GrUpdate } from "react-icons/gr";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
import { getRecentActivity, getSSHHosts } from "@/ui/main-axios.ts";
|
||||
import type { RecentActivityItem } from "@/ui/main-axios.ts";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder: string;
|
||||
tags: string[];
|
||||
pin: boolean;
|
||||
authType: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableFileManager: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: unknown[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function CommandPalette({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { addTab, setCurrentTab, tabs: tabList, updateTab } = useTabs();
|
||||
const [recentActivity, setRecentActivity] = useState<RecentActivityItem[]>(
|
||||
[],
|
||||
);
|
||||
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
inputRef.current?.focus();
|
||||
getRecentActivity(50).then((activity) => {
|
||||
setRecentActivity(activity.slice(0, 5));
|
||||
});
|
||||
getSSHHosts().then((allHosts) => {
|
||||
setHosts(allHosts);
|
||||
});
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleAddHost = () => {
|
||||
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
|
||||
if (sshManagerTab) {
|
||||
updateTab(sshManagerTab.id, { initialTab: "add_host" });
|
||||
setCurrentTab(sshManagerTab.id);
|
||||
} else {
|
||||
const id = addTab({
|
||||
type: "ssh_manager",
|
||||
title: t("commandPalette.hostManager"),
|
||||
initialTab: "add_host",
|
||||
});
|
||||
setCurrentTab(id);
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleAddCredential = () => {
|
||||
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
|
||||
if (sshManagerTab) {
|
||||
updateTab(sshManagerTab.id, { initialTab: "add_credential" });
|
||||
setCurrentTab(sshManagerTab.id);
|
||||
} else {
|
||||
const id = addTab({
|
||||
type: "ssh_manager",
|
||||
title: t("commandPalette.hostManager"),
|
||||
initialTab: "add_credential",
|
||||
});
|
||||
setCurrentTab(id);
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleOpenAdminSettings = () => {
|
||||
const adminTab = tabList.find((t) => t.type === "admin");
|
||||
if (adminTab) {
|
||||
setCurrentTab(adminTab.id);
|
||||
} else {
|
||||
const id = addTab({
|
||||
type: "admin",
|
||||
title: t("commandPalette.adminSettings"),
|
||||
});
|
||||
setCurrentTab(id);
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleOpenUserProfile = () => {
|
||||
const userProfileTab = tabList.find((t) => t.type === "user_profile");
|
||||
if (userProfileTab) {
|
||||
setCurrentTab(userProfileTab.id);
|
||||
} else {
|
||||
const id = addTab({
|
||||
type: "user_profile",
|
||||
title: t("commandPalette.userProfile"),
|
||||
});
|
||||
setCurrentTab(id);
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleOpenUpdateLog = () => {
|
||||
window.open("https://github.com/Termix-SSH/Termix/releases", "_blank");
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleGitHub = () => {
|
||||
window.open("https://github.com/Termix-SSH/Termix", "_blank");
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleSupport = () => {
|
||||
window.open("https://github.com/Termix-SSH/Support/issues/new", "_blank");
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleDiscord = () => {
|
||||
window.open("https://discord.com/invite/jVQGdvHDrf", "_blank");
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleDonate = () => {
|
||||
window.open("https://github.com/sponsors/LukeGus", "_blank");
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleActivityClick = (item: RecentActivityItem) => {
|
||||
getSSHHosts().then((hosts) => {
|
||||
const host = hosts.find((h: { id: number }) => h.id === item.hostId);
|
||||
if (!host) return;
|
||||
|
||||
if (item.type === "terminal") {
|
||||
addTab({
|
||||
type: "terminal",
|
||||
title: item.hostName,
|
||||
hostConfig: host,
|
||||
});
|
||||
} else if (item.type === "file_manager") {
|
||||
addTab({
|
||||
type: "file_manager",
|
||||
title: item.hostName,
|
||||
hostConfig: host,
|
||||
});
|
||||
}
|
||||
});
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleHostTerminalClick = (host: SSHHost) => {
|
||||
const title = host.name?.trim()
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
addTab({ type: "terminal", title, hostConfig: host });
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleHostFileManagerClick = (host: SSHHost) => {
|
||||
const title = host.name?.trim()
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
addTab({ type: "file_manager", title, hostConfig: host });
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleHostServerDetailsClick = (host: SSHHost) => {
|
||||
const title = host.name?.trim()
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
addTab({ type: "server", title, hostConfig: host });
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleHostEditClick = (host: SSHHost) => {
|
||||
const title = host.name?.trim()
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
addTab({
|
||||
type: "ssh_manager",
|
||||
title: t("commandPalette.hostManager"),
|
||||
hostConfig: host,
|
||||
initialTab: "add_host",
|
||||
});
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 flex items-center justify-center bg-black/30 transition-opacity duration-200",
|
||||
!isOpen && "opacity-0 pointer-events-none",
|
||||
)}
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
<Command
|
||||
className={cn(
|
||||
"w-3/4 max-w-2xl max-h-[60vh] rounded-lg border-2 border-dark-border shadow-md flex flex-col",
|
||||
"transition-all duration-200 ease-out",
|
||||
!isOpen && "scale-95 opacity-0",
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<CommandInput
|
||||
ref={inputRef}
|
||||
placeholder={t("commandPalette.searchPlaceholder")}
|
||||
/>
|
||||
<CommandList
|
||||
key={recentActivity.length}
|
||||
className="w-full h-auto flex-grow overflow-y-auto"
|
||||
style={{ maxHeight: "inherit" }}
|
||||
>
|
||||
{recentActivity.length > 0 && (
|
||||
<>
|
||||
<CommandGroup heading={t("commandPalette.recentActivity")}>
|
||||
{recentActivity.map((item, index) => (
|
||||
<CommandItem
|
||||
key={`recent-activity-${index}-${item.type}-${item.hostId}-${item.timestamp}`}
|
||||
value={`recent-activity-${index}-${item.hostName}-${item.type}`}
|
||||
onSelect={() => handleActivityClick(item)}
|
||||
>
|
||||
{item.type === "terminal" ? <Terminal /> : <FolderOpen />}
|
||||
<span>{item.hostName}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
</>
|
||||
)}
|
||||
<CommandGroup heading={t("commandPalette.navigation")}>
|
||||
<CommandItem onSelect={handleAddHost}>
|
||||
<Server />
|
||||
<span>{t("commandPalette.addHost")}</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleAddCredential}>
|
||||
<Key />
|
||||
<span>{t("commandPalette.addCredential")}</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleOpenAdminSettings}>
|
||||
<Settings />
|
||||
<span>{t("commandPalette.adminSettings")}</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleOpenUserProfile}>
|
||||
<User />
|
||||
<span>{t("commandPalette.userProfile")}</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleOpenUpdateLog}>
|
||||
<GrUpdate />
|
||||
<span>{t("commandPalette.updateLog")}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
{hosts.length > 0 && (
|
||||
<>
|
||||
<CommandGroup heading={t("commandPalette.hosts")}>
|
||||
{hosts.map((host, index) => {
|
||||
const title = host.name?.trim()
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
return (
|
||||
<CommandItem
|
||||
key={`host-${index}-${host.id}`}
|
||||
value={`host-${index}-${title}-${host.id}`}
|
||||
onSelect={() => {
|
||||
if (host.enableTerminal) {
|
||||
handleHostTerminalClick(host);
|
||||
}
|
||||
}}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4" />
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 h-7 border-1 border-dark-border"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<EllipsisVertical className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
side="right"
|
||||
className="w-56 bg-dark-bg border-dark-border text-white"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleHostServerDetailsClick(host);
|
||||
}}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
|
||||
>
|
||||
<Server className="h-4 w-4" />
|
||||
<span className="flex-1">
|
||||
{t("commandPalette.openServerDetails")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleHostFileManagerClick(host);
|
||||
}}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
<span className="flex-1">
|
||||
{t("commandPalette.openFileManager")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleHostEditClick(host);
|
||||
}}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<span className="flex-1">
|
||||
{t("commandPalette.edit")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
</>
|
||||
)}
|
||||
<CommandGroup heading={t("commandPalette.links")}>
|
||||
<CommandItem onSelect={handleGitHub}>
|
||||
<Github />
|
||||
<span>{t("commandPalette.github")}</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleSupport}>
|
||||
<BiSupport />
|
||||
<span>{t("commandPalette.support")}</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleDiscord}>
|
||||
<BsDiscord />
|
||||
<span>{t("commandPalette.discord")}</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleDonate}>
|
||||
<BiMoney />
|
||||
<span>{t("commandPalette.donate")}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
<div className="border-t border-dark-border px-4 py-2 bg-dark-hover/50 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("commandPalette.press")}</span>
|
||||
<KbdGroup>
|
||||
<Kbd>Shift</Kbd>
|
||||
<Kbd>Shift</Kbd>
|
||||
</KbdGroup>
|
||||
<span>{t("commandPalette.toToggle")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("commandPalette.close")}</span>
|
||||
<Kbd>Esc</Kbd>
|
||||
</div>
|
||||
</div>
|
||||
</Command>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -52,6 +53,8 @@ export function CredentialEditor({
|
||||
const [detectedKeyType, setDetectedKeyType] = useState<string | null>(null);
|
||||
const [keyDetectionLoading, setKeyDetectionLoading] = useState(false);
|
||||
const keyDetectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [activeTab, setActiveTab] = useState("general");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const [detectedPublicKeyType, setDetectedPublicKeyType] = useState<
|
||||
string | null
|
||||
@@ -60,6 +63,10 @@ export function CredentialEditor({
|
||||
useState(false);
|
||||
const publicKeyDetectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setFormError(null);
|
||||
}, [activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
@@ -320,6 +327,8 @@ export function CredentialEditor({
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
try {
|
||||
setFormError(null);
|
||||
|
||||
if (!data.name || data.name.trim() === "") {
|
||||
data.name = data.username;
|
||||
}
|
||||
@@ -378,6 +387,28 @@ export function CredentialEditor({
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormError = () => {
|
||||
const errors = form.formState.errors;
|
||||
|
||||
if (
|
||||
errors.name ||
|
||||
errors.username ||
|
||||
errors.description ||
|
||||
errors.folder ||
|
||||
errors.tags
|
||||
) {
|
||||
setActiveTab("general");
|
||||
} else if (
|
||||
errors.password ||
|
||||
errors.key ||
|
||||
errors.publicKey ||
|
||||
errors.keyPassword ||
|
||||
errors.keyType
|
||||
) {
|
||||
setActiveTab("authentication");
|
||||
}
|
||||
};
|
||||
|
||||
const [tagInput, setTagInput] = useState("");
|
||||
|
||||
const [folderDropdownOpen, setFolderDropdownOpen] = useState(false);
|
||||
@@ -427,11 +458,20 @@ export function CredentialEditor({
|
||||
>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
onSubmit={form.handleSubmit(onSubmit, handleFormError)}
|
||||
className="flex flex-col flex-1 min-h-0 h-full"
|
||||
>
|
||||
<ScrollArea className="flex-1 min-h-0 w-full my-1 pb-2">
|
||||
<Tabs defaultValue="general" className="w-full">
|
||||
{formError && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertDescription>{formError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="general">
|
||||
{t("credentials.general")}
|
||||
|
||||
@@ -16,6 +16,18 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Search,
|
||||
Key,
|
||||
@@ -32,7 +44,9 @@ import {
|
||||
Upload,
|
||||
Server,
|
||||
User,
|
||||
ChevronsUpDown,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
getCredentials,
|
||||
deleteCredential,
|
||||
@@ -82,9 +96,7 @@ export function CredentialsManager({
|
||||
>([]);
|
||||
const [selectedHostId, setSelectedHostId] = useState<string>("");
|
||||
const [deployLoading, setDeployLoading] = useState(false);
|
||||
const [hostSearchQuery, setHostSearchQuery] = useState("");
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [hostComboboxOpen, setHostComboboxOpen] = useState(false);
|
||||
const dragCounter = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -94,41 +106,11 @@ export function CredentialsManager({
|
||||
|
||||
useEffect(() => {
|
||||
if (showDeployDialog) {
|
||||
setDropdownOpen(false);
|
||||
setHostSearchQuery("");
|
||||
setHostComboboxOpen(false);
|
||||
setSelectedHostId("");
|
||||
setTimeout(() => {
|
||||
if (
|
||||
document.activeElement &&
|
||||
(document.activeElement as HTMLElement).blur
|
||||
) {
|
||||
(document.activeElement as HTMLElement).blur();
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
}, [showDeployDialog]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (dropdownOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
} else {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [dropdownOpen]);
|
||||
|
||||
const fetchHosts = async () => {
|
||||
try {
|
||||
const hosts = await getSSHHosts();
|
||||
@@ -168,8 +150,7 @@ export function CredentialsManager({
|
||||
}
|
||||
setDeployingCredential(credential);
|
||||
setSelectedHostId("");
|
||||
setHostSearchQuery("");
|
||||
setDropdownOpen(false);
|
||||
setHostComboboxOpen(false);
|
||||
setShowDeployDialog(true);
|
||||
};
|
||||
|
||||
@@ -640,6 +621,9 @@ export function CredentialsManager({
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{credential.username}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
ID: {credential.id}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{credential.authType === "password"
|
||||
? t("credentials.password")
|
||||
@@ -824,13 +808,10 @@ export function CredentialsManager({
|
||||
)}
|
||||
|
||||
<Sheet open={showDeployDialog} onOpenChange={setShowDeployDialog}>
|
||||
<SheetContent className="w-[500px] max-w-[50vw] overflow-y-auto">
|
||||
<SheetContent className="w-[500px] max-w-[50vw] overflow-y-auto bg-dark-bg">
|
||||
<div className="px-4 py-4">
|
||||
<div className="space-y-3 pb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 rounded-lg bg-green-100 dark:bg-green-900/30">
|
||||
<Upload className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-lg font-semibold">
|
||||
{t("credentials.deploySSHKey")}
|
||||
@@ -899,67 +880,62 @@ export function CredentialsManager({
|
||||
<Server className="h-4 w-4 mr-2 text-muted-foreground" />
|
||||
{t("credentials.targetHost")}
|
||||
</label>
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<Input
|
||||
placeholder={t("credentials.chooseHostToDeploy")}
|
||||
value={hostSearchQuery}
|
||||
onChange={(e) => {
|
||||
setHostSearchQuery(e.target.value);
|
||||
}}
|
||||
onClick={() => {
|
||||
setDropdownOpen(true);
|
||||
}}
|
||||
className="w-full"
|
||||
autoFocus={false}
|
||||
tabIndex={0}
|
||||
/>
|
||||
{dropdownOpen && (
|
||||
<div className="absolute top-full left-0 z-50 mt-1 w-full bg-card border border-border rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
||||
{availableHosts.length === 0 ? (
|
||||
<div className="p-3 text-sm text-muted-foreground text-center">
|
||||
{t("credentials.noHostsAvailable")}
|
||||
</div>
|
||||
) : availableHosts.filter(
|
||||
(host) =>
|
||||
!hostSearchQuery ||
|
||||
host.name
|
||||
?.toLowerCase()
|
||||
.includes(hostSearchQuery.toLowerCase()) ||
|
||||
host.ip
|
||||
?.toLowerCase()
|
||||
.includes(hostSearchQuery.toLowerCase()) ||
|
||||
host.username
|
||||
?.toLowerCase()
|
||||
.includes(hostSearchQuery.toLowerCase()),
|
||||
).length === 0 ? (
|
||||
<div className="p-3 text-sm text-muted-foreground text-center">
|
||||
{t("credentials.noHostsMatchSearch")}
|
||||
</div>
|
||||
) : (
|
||||
availableHosts
|
||||
.filter(
|
||||
(host) =>
|
||||
!hostSearchQuery ||
|
||||
host.name
|
||||
?.toLowerCase()
|
||||
.includes(hostSearchQuery.toLowerCase()) ||
|
||||
host.ip
|
||||
?.toLowerCase()
|
||||
.includes(hostSearchQuery.toLowerCase()) ||
|
||||
host.username
|
||||
?.toLowerCase()
|
||||
.includes(hostSearchQuery.toLowerCase()),
|
||||
)
|
||||
.map((host) => (
|
||||
<div
|
||||
key={host.id}
|
||||
className="flex items-center gap-3 py-2 px-3 hover:bg-muted cursor-pointer"
|
||||
onClick={() => {
|
||||
setSelectedHostId(host.id.toString());
|
||||
setHostSearchQuery(host.name || host.ip);
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
<Popover
|
||||
open={hostComboboxOpen}
|
||||
onOpenChange={setHostComboboxOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={hostComboboxOpen}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
{selectedHostId
|
||||
? (() => {
|
||||
const host = availableHosts.find(
|
||||
(h) => h.id.toString() === selectedHostId,
|
||||
);
|
||||
return host
|
||||
? `${host.name || host.ip}`
|
||||
: t("credentials.chooseHostToDeploy");
|
||||
})()
|
||||
: t("credentials.chooseHostToDeploy")}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t("credentials.chooseHostToDeploy")}
|
||||
/>
|
||||
<CommandEmpty>
|
||||
{availableHosts.length === 0
|
||||
? t("credentials.noHostsAvailable")
|
||||
: t("credentials.noHostsMatchSearch")}
|
||||
</CommandEmpty>
|
||||
<CommandGroup className="max-h-[300px] overflow-y-auto">
|
||||
{availableHosts.map((host) => (
|
||||
<CommandItem
|
||||
key={host.id}
|
||||
value={`${host.name} ${host.ip} ${host.username} ${host.id}`}
|
||||
onSelect={() => {
|
||||
setSelectedHostId(host.id.toString());
|
||||
setHostComboboxOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedHostId === host.id.toString()
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-1.5 rounded bg-muted">
|
||||
<Server className="h-3 w-3 text-muted-foreground" />
|
||||
</div>
|
||||
@@ -972,11 +948,12 @@ export function CredentialsManager({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="border border-blue-200 dark:border-blue-800 rounded-lg p-3 bg-blue-50 dark:bg-blue-900/20">
|
||||
@@ -1006,7 +983,7 @@ export function CredentialsManager({
|
||||
<Button
|
||||
onClick={performDeploy}
|
||||
disabled={!selectedHostId || deployLoading}
|
||||
className="flex-1 bg-green-600 hover:bg-green-700 text-white"
|
||||
className="flex-1"
|
||||
>
|
||||
{deployLoading ? (
|
||||
<div className="flex items-center">
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
import { Kbd, KbdGroup } from "@/components/ui/kbd";
|
||||
import {
|
||||
ChartLine,
|
||||
Clock,
|
||||
@@ -50,6 +51,8 @@ interface DashboardProps {
|
||||
userId: string | null;
|
||||
}) => void;
|
||||
isTopbarOpen: boolean;
|
||||
rightSidebarOpen?: boolean;
|
||||
rightSidebarWidth?: number;
|
||||
}
|
||||
|
||||
export function Dashboard({
|
||||
@@ -58,6 +61,8 @@ export function Dashboard({
|
||||
onAuthSuccess,
|
||||
isTopbarOpen,
|
||||
onSelectView,
|
||||
rightSidebarOpen = false,
|
||||
rightSidebarWidth = 400,
|
||||
}: DashboardProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [loggedIn, setLoggedIn] = useState(isAuthenticated);
|
||||
@@ -85,16 +90,19 @@ export function Dashboard({
|
||||
>([]);
|
||||
const [serverStatsLoading, setServerStatsLoading] = useState<boolean>(true);
|
||||
|
||||
const { addTab, setCurrentTab, tabs: tabList } = useTabs();
|
||||
const { addTab, setCurrentTab, tabs: tabList, updateTab } = useTabs();
|
||||
|
||||
let sidebarState: "expanded" | "collapsed" = "expanded";
|
||||
try {
|
||||
const sidebar = useSidebar();
|
||||
sidebarState = sidebar.state;
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
console.error("Dashboard operation failed:", error);
|
||||
}
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
|
||||
const rightMarginPx = 17;
|
||||
const bottomMarginPx = 8;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -173,7 +181,9 @@ export function Dashboard({
|
||||
if (Array.isArray(tunnelConnections)) {
|
||||
totalTunnelsCount += tunnelConnections.length;
|
||||
}
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
console.error("Dashboard operation failed:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
setTotalTunnels(totalTunnelsCount);
|
||||
@@ -194,27 +204,57 @@ export function Dashboard({
|
||||
|
||||
setServerStatsLoading(true);
|
||||
const serversWithStats = await Promise.all(
|
||||
hosts.slice(0, 50).map(async (host: { id: number; name: string }) => {
|
||||
try {
|
||||
const metrics = await getServerMetricsById(host.id);
|
||||
return {
|
||||
id: host.id,
|
||||
name: host.name || `Host ${host.id}`,
|
||||
cpu: metrics.cpu.percent,
|
||||
ram: metrics.memory.percent,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
id: host.id,
|
||||
name: host.name || `Host ${host.id}`,
|
||||
cpu: null,
|
||||
ram: null,
|
||||
};
|
||||
}
|
||||
}),
|
||||
hosts
|
||||
.slice(0, 50)
|
||||
.map(
|
||||
async (host: {
|
||||
id: number;
|
||||
name: string;
|
||||
statsConfig?: string | { metricsEnabled?: boolean };
|
||||
}) => {
|
||||
try {
|
||||
let statsConfig: { metricsEnabled?: boolean } = {
|
||||
metricsEnabled: true,
|
||||
};
|
||||
if (host.statsConfig) {
|
||||
if (typeof host.statsConfig === "string") {
|
||||
statsConfig = JSON.parse(host.statsConfig);
|
||||
} else {
|
||||
statsConfig = host.statsConfig;
|
||||
}
|
||||
}
|
||||
|
||||
if (statsConfig.metricsEnabled === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metrics = await getServerMetricsById(host.id);
|
||||
return {
|
||||
id: host.id,
|
||||
name: host.name || `Host ${host.id}`,
|
||||
cpu: metrics.cpu.percent,
|
||||
ram: metrics.memory.percent,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
id: host.id,
|
||||
name: host.name || `Host ${host.id}`,
|
||||
cpu: null,
|
||||
ram: null,
|
||||
};
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
const validServerStats = serversWithStats.filter(
|
||||
(server) => server.cpu !== null && server.ram !== null,
|
||||
(
|
||||
server,
|
||||
): server is {
|
||||
id: number;
|
||||
name: string;
|
||||
cpu: number | null;
|
||||
ram: number | null;
|
||||
} => server !== null && server.cpu !== null && server.ram !== null,
|
||||
);
|
||||
setServerStats(validServerStats);
|
||||
setServerStatsLoading(false);
|
||||
@@ -264,6 +304,7 @@ export function Dashboard({
|
||||
const handleAddHost = () => {
|
||||
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
|
||||
if (sshManagerTab) {
|
||||
updateTab(sshManagerTab.id, { initialTab: "add_host" });
|
||||
setCurrentTab(sshManagerTab.id);
|
||||
} else {
|
||||
const id = addTab({
|
||||
@@ -278,6 +319,7 @@ export function Dashboard({
|
||||
const handleAddCredential = () => {
|
||||
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
|
||||
if (sshManagerTab) {
|
||||
updateTab(sshManagerTab.id, { initialTab: "add_credential" });
|
||||
setCurrentTab(sshManagerTab.id);
|
||||
} else {
|
||||
const id = addTab({
|
||||
@@ -327,23 +369,32 @@ export function Dashboard({
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden flex"
|
||||
className="bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden flex min-w-0"
|
||||
style={{
|
||||
marginLeft: leftMarginPx,
|
||||
marginRight: 17,
|
||||
marginRight: rightSidebarOpen
|
||||
? `calc(var(--right-sidebar-width, ${rightSidebarWidth}px) + 8px)`
|
||||
: rightMarginPx,
|
||||
marginTop: topMarginPx,
|
||||
marginBottom: bottomMarginPx,
|
||||
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
|
||||
transition:
|
||||
"margin-left 200ms linear, margin-right 200ms linear, margin-top 200ms linear",
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col relative z-10 w-full h-full">
|
||||
<div className="flex flex-row items-center justify-between w-full px-3 mt-3">
|
||||
<div className="text-2xl text-white font-semibold">
|
||||
<div className="flex flex-col relative z-10 w-full h-full min-w-0">
|
||||
<div className="flex flex-row items-center justify-between w-full px-3 mt-3 min-w-0 flex-wrap gap-2">
|
||||
<div className="text-2xl text-white font-semibold shrink-0">
|
||||
{t("dashboard.title")}
|
||||
</div>
|
||||
<div className="flex flex-row gap-3">
|
||||
<div className="flex flex-row gap-3 flex-wrap min-w-0">
|
||||
<div className="flex flex-col items-center gap-4 justify-center mr-5 min-w-0 shrink">
|
||||
<p className="text-muted-foreground text-sm whitespace-nowrap">
|
||||
Press <Kbd>LShift</Kbd> twice to open the command palette
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="font-semibold"
|
||||
className="font-semibold shrink-0"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
@@ -355,7 +406,7 @@ export function Dashboard({
|
||||
{t("dashboard.github")}
|
||||
</Button>
|
||||
<Button
|
||||
className="font-semibold"
|
||||
className="font-semibold shrink-0"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
@@ -367,7 +418,7 @@ export function Dashboard({
|
||||
{t("dashboard.support")}
|
||||
</Button>
|
||||
<Button
|
||||
className="font-semibold"
|
||||
className="font-semibold shrink-0"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
@@ -379,7 +430,7 @@ export function Dashboard({
|
||||
{t("dashboard.discord")}
|
||||
</Button>
|
||||
<Button
|
||||
className="font-semibold"
|
||||
className="font-semibold shrink-0"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
window.open("https://github.com/sponsors/LukeGus", "_blank")
|
||||
@@ -392,23 +443,23 @@ export function Dashboard({
|
||||
|
||||
<Separator className="mt-3 p-0.25" />
|
||||
|
||||
<div className="flex flex-col flex-1 my-5 mx-5 gap-4 min-h-0">
|
||||
<div className="flex flex-row flex-1 gap-4 min-h-0">
|
||||
<div className="flex-1 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden">
|
||||
<div className="flex flex-col flex-1 my-5 mx-5 gap-4 min-h-0 min-w-0">
|
||||
<div className="flex flex-row flex-1 gap-4 min-h-0 min-w-0">
|
||||
<div className="flex-1 min-w-0 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden">
|
||||
<div className="flex flex-col mx-3 my-2 overflow-y-auto overflow-x-hidden">
|
||||
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
|
||||
<Server className="mr-3" />
|
||||
{t("dashboard.serverOverview")}
|
||||
</p>
|
||||
<div className="bg-dark-bg w-full h-auto border-2 border-dark-border rounded-md px-3 py-3">
|
||||
<div className="flex flex-row items-center justify-between mb-3">
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="flex flex-row items-center justify-between mb-3 min-w-0 gap-2">
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<History
|
||||
size={20}
|
||||
color="#FFFFFF"
|
||||
className="shrink-0"
|
||||
/>
|
||||
<p className="ml-2 leading-none">
|
||||
<p className="ml-2 leading-none truncate">
|
||||
{t("dashboard.version")}
|
||||
</p>
|
||||
</div>
|
||||
@@ -430,14 +481,14 @@ export function Dashboard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center justify-between mb-5">
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="flex flex-row items-center justify-between mb-5 min-w-0 gap-2">
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<Clock
|
||||
size={20}
|
||||
color="#FFFFFF"
|
||||
className="shrink-0"
|
||||
/>
|
||||
<p className="ml-2 leading-none">
|
||||
<p className="ml-2 leading-none truncate">
|
||||
{t("dashboard.uptime")}
|
||||
</p>
|
||||
</div>
|
||||
@@ -449,14 +500,14 @@ export function Dashboard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="flex flex-row items-center justify-between min-w-0 gap-2">
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<Database
|
||||
size={20}
|
||||
color="#FFFFFF"
|
||||
className="shrink-0"
|
||||
/>
|
||||
<p className="ml-2 leading-none">
|
||||
<p className="ml-2 leading-none truncate">
|
||||
{t("dashboard.database")}
|
||||
</p>
|
||||
</div>
|
||||
@@ -473,14 +524,14 @@ export function Dashboard({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col grid grid-cols-2 gap-2 mt-2">
|
||||
<div className="flex flex-row items-center justify-between bg-dark-bg w-full h-auto mt-3 border-2 border-dark-border rounded-md px-3 py-3">
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="flex flex-row items-center justify-between bg-dark-bg w-full h-auto mt-3 border-2 border-dark-border rounded-md px-3 py-3 min-w-0 gap-2">
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<Server
|
||||
size={16}
|
||||
color="#FFFFFF"
|
||||
className="mr-3 shrink-0"
|
||||
/>
|
||||
<p className="m-0 leading-none">
|
||||
<p className="m-0 leading-none truncate">
|
||||
{t("dashboard.totalServers")}
|
||||
</p>
|
||||
</div>
|
||||
@@ -488,14 +539,14 @@ export function Dashboard({
|
||||
{totalServers}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-between bg-dark-bg w-full h-auto mt-3 border-2 border-dark-border rounded-md px-3 py-3">
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="flex flex-row items-center justify-between bg-dark-bg w-full h-auto mt-3 border-2 border-dark-border rounded-md px-3 py-3 min-w-0 gap-2">
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<Network
|
||||
size={16}
|
||||
color="#FFFFFF"
|
||||
className="mr-3 shrink-0"
|
||||
/>
|
||||
<p className="m-0 leading-none">
|
||||
<p className="m-0 leading-none truncate">
|
||||
{t("dashboard.totalTunnels")}
|
||||
</p>
|
||||
</div>
|
||||
@@ -505,14 +556,14 @@ export function Dashboard({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col grid grid-cols-2 gap-2 mt-2">
|
||||
<div className="flex flex-row items-center justify-between bg-dark-bg w-full h-auto mt-3 border-2 border-dark-border rounded-md px-3 py-3">
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="flex flex-row items-center justify-between bg-dark-bg w-full h-auto mt-3 border-2 border-dark-border rounded-md px-3 py-3 min-w-0 gap-2">
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<Key
|
||||
size={16}
|
||||
color="#FFFFFF"
|
||||
className="mr-3 shrink-0"
|
||||
/>
|
||||
<p className="m-0 leading-none">
|
||||
<p className="m-0 leading-none truncate">
|
||||
{t("dashboard.totalCredentials")}
|
||||
</p>
|
||||
</div>
|
||||
@@ -523,7 +574,7 @@ export function Dashboard({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden">
|
||||
<div className="flex-1 min-w-0 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
|
||||
<div className="flex flex-col mx-3 my-2 flex-1 overflow-hidden">
|
||||
<div className="flex flex-row items-center justify-between mb-3 mt-1">
|
||||
<p className="text-xl font-semibold flex flex-row items-center">
|
||||
@@ -540,10 +591,10 @@ export function Dashboard({
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className={`grid gap-4 grid-cols-[repeat(auto-fit,minmax(200px,1fr))] auto-rows-min overflow-x-hidden ${recentActivityLoading ? "overflow-y-hidden" : "overflow-y-auto"}`}
|
||||
className={`grid gap-4 grid-cols-3 auto-rows-min overflow-x-hidden ${recentActivityLoading ? "overflow-y-hidden" : "overflow-y-auto"}`}
|
||||
>
|
||||
{recentActivityLoading ? (
|
||||
<div className="flex flex-row items-center text-muted-foreground text-sm">
|
||||
<div className="flex flex-row items-center text-muted-foreground text-sm animate-pulse">
|
||||
<Loader2 className="animate-spin mr-2" size={16} />
|
||||
<span>{t("dashboard.loadingRecentActivity")}</span>
|
||||
</div>
|
||||
@@ -556,7 +607,7 @@ export function Dashboard({
|
||||
<Button
|
||||
key={item.id}
|
||||
variant="outline"
|
||||
className="border-2 !border-dark-border bg-dark-bg"
|
||||
className="border-2 !border-dark-border bg-dark-bg min-w-0"
|
||||
onClick={() => handleActivityClick(item)}
|
||||
>
|
||||
{item.type === "terminal" ? (
|
||||
@@ -574,17 +625,17 @@ export function Dashboard({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row flex-1 gap-4 min-h-0">
|
||||
<div className="flex-1 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden">
|
||||
<div className="flex flex-row flex-1 gap-4 min-h-0 min-w-0">
|
||||
<div className="flex-1 min-w-0 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
|
||||
<div className="flex flex-col mx-3 my-2 overflow-y-auto overflow-x-hidden">
|
||||
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
|
||||
<FastForward className="mr-3" />
|
||||
{t("dashboard.quickActions")}
|
||||
</p>
|
||||
<div className="grid gap-4 grid-cols-[repeat(auto-fit,minmax(200px,1fr))] auto-rows-min overflow-y-auto overflow-x-hidden">
|
||||
<div className="grid gap-4 grid-cols-3 auto-rows-min overflow-y-auto overflow-x-hidden">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3"
|
||||
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3 min-w-0"
|
||||
onClick={handleAddHost}
|
||||
>
|
||||
<Server
|
||||
@@ -597,7 +648,7 @@ export function Dashboard({
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3"
|
||||
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3 min-w-0"
|
||||
onClick={handleAddCredential}
|
||||
>
|
||||
<Key
|
||||
@@ -611,7 +662,7 @@ export function Dashboard({
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3"
|
||||
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3 min-w-0"
|
||||
onClick={handleOpenAdminSettings}
|
||||
>
|
||||
<Settings
|
||||
@@ -625,7 +676,7 @@ export function Dashboard({
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3"
|
||||
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3 min-w-0"
|
||||
onClick={handleOpenUserProfile}
|
||||
>
|
||||
<User
|
||||
@@ -639,17 +690,17 @@ export function Dashboard({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden">
|
||||
<div className="flex-1 min-w-0 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
|
||||
<div className="flex flex-col mx-3 my-2 flex-1 overflow-hidden">
|
||||
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
|
||||
<ChartLine className="mr-3" />
|
||||
{t("dashboard.serverStats")}
|
||||
</p>
|
||||
<div
|
||||
className={`grid gap-4 grid-cols-[repeat(auto-fit,minmax(200px,1fr))] auto-rows-min overflow-x-hidden ${serverStatsLoading ? "overflow-y-hidden" : "overflow-y-auto"}`}
|
||||
className={`grid gap-4 grid-cols-3 auto-rows-min overflow-x-hidden ${serverStatsLoading ? "overflow-y-hidden" : "overflow-y-auto"}`}
|
||||
>
|
||||
{serverStatsLoading ? (
|
||||
<div className="flex flex-row items-center text-muted-foreground text-sm">
|
||||
<div className="flex flex-row items-center text-muted-foreground text-sm animate-pulse">
|
||||
<Loader2 className="animate-spin mr-2" size={16} />
|
||||
<span>{t("dashboard.loadingServerStats")}</span>
|
||||
</div>
|
||||
@@ -662,7 +713,7 @@ export function Dashboard({
|
||||
<Button
|
||||
key={server.id}
|
||||
variant="outline"
|
||||
className="border-2 !border-dark-border bg-dark-bg h-auto p-3"
|
||||
className="border-2 !border-dark-border bg-dark-bg h-auto p-3 min-w-0"
|
||||
>
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex flex-row items-center mb-2">
|
||||
@@ -671,7 +722,7 @@ export function Dashboard({
|
||||
{server.name}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-row justify-between text-xs text-muted-foreground">
|
||||
<div className="flex flex-row justify-start gap-4 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t("dashboard.cpu")}:{" "}
|
||||
{server.cpu !== null
|
||||
|
||||
126
src/ui/desktop/apps/docker/DockerManager.tsx
Normal file
126
src/ui/desktop/apps/docker/DockerManager.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import React from "react";
|
||||
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface HostConfig {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
username: string;
|
||||
folder?: string;
|
||||
enableFileManager?: boolean;
|
||||
tunnelConnections?: unknown[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface DockerManagerProps {
|
||||
hostConfig?: HostConfig;
|
||||
title?: string;
|
||||
isVisible?: boolean;
|
||||
isTopbarOpen?: boolean;
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export function DockerManager({
|
||||
hostConfig,
|
||||
title,
|
||||
isVisible = true,
|
||||
isTopbarOpen = true,
|
||||
embedded = false,
|
||||
}: DockerManagerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { state: sidebarState } = useSidebar();
|
||||
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hostConfig?.id !== currentHostConfig?.id) {
|
||||
setCurrentHostConfig(hostConfig);
|
||||
}
|
||||
}, [hostConfig?.id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchLatestHostConfig = async () => {
|
||||
if (hostConfig?.id) {
|
||||
try {
|
||||
const { getSSHHosts } = await import("@/ui/main-axios.ts");
|
||||
const hosts = await getSSHHosts();
|
||||
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
|
||||
if (updatedHost) {
|
||||
setCurrentHostConfig(updatedHost);
|
||||
}
|
||||
} catch {
|
||||
// Silently handle error
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchLatestHostConfig();
|
||||
|
||||
const handleHostsChanged = async () => {
|
||||
if (hostConfig?.id) {
|
||||
try {
|
||||
const { getSSHHosts } = await import("@/ui/main-axios.ts");
|
||||
const hosts = await getSSHHosts();
|
||||
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
|
||||
if (updatedHost) {
|
||||
setCurrentHostConfig(updatedHost);
|
||||
}
|
||||
} catch {
|
||||
// Silently handle error
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||
return () =>
|
||||
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||
}, [hostConfig?.id]);
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 16;
|
||||
const leftMarginPx = sidebarState === "collapsed" ? 16 : 8;
|
||||
const bottomMarginPx = 8;
|
||||
|
||||
const wrapperStyle: React.CSSProperties = embedded
|
||||
? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" }
|
||||
: {
|
||||
opacity: isVisible ? 1 : 0,
|
||||
marginLeft: leftMarginPx,
|
||||
marginRight: 17,
|
||||
marginTop: topMarginPx,
|
||||
marginBottom: bottomMarginPx,
|
||||
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
|
||||
};
|
||||
|
||||
const containerClass = embedded
|
||||
? "h-full w-full text-white overflow-hidden bg-transparent"
|
||||
: "bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden";
|
||||
|
||||
return (
|
||||
<div style={wrapperStyle} className={containerClass}>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-bold text-lg truncate">
|
||||
{currentHostConfig?.folder} / {title}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="p-0.25 w-full" />
|
||||
|
||||
<div className="flex-1 overflow-hidden min-h-0 p-1">
|
||||
{/* Empty body as requested */}
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-400 text-lg">
|
||||
Docker management UI will be here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx";
|
||||
import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx";
|
||||
import { PermissionsDialog } from "./components/PermissionsDialog";
|
||||
import { CompressDialog } from "./components/CompressDialog";
|
||||
import {
|
||||
Upload,
|
||||
FolderPlus,
|
||||
@@ -49,6 +51,9 @@ import {
|
||||
addFolderShortcut,
|
||||
getPinnedFiles,
|
||||
logActivity,
|
||||
changeSSHPermissions,
|
||||
extractSSHArchive,
|
||||
compressSSHFiles,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import type { SidebarItem } from "./FileManagerSidebar";
|
||||
|
||||
@@ -97,7 +102,10 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
const [isReconnecting, setIsReconnecting] = useState<boolean>(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [lastRefreshTime, setLastRefreshTime] = useState<number>(0);
|
||||
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
||||
const [viewMode, setViewMode] = useState<"grid" | "list">(() => {
|
||||
const saved = localStorage.getItem("fileManagerViewMode");
|
||||
return saved === "grid" || saved === "list" ? saved : "grid";
|
||||
});
|
||||
const [totpRequired, setTotpRequired] = useState(false);
|
||||
const [totpSessionId, setTotpSessionId] = useState<string | null>(null);
|
||||
const [totpPrompt, setTotpPrompt] = useState<string>("");
|
||||
@@ -146,6 +154,11 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
|
||||
const [createIntent, setCreateIntent] = useState<CreateIntent | null>(null);
|
||||
const [editingFile, setEditingFile] = useState<FileItem | null>(null);
|
||||
const [permissionsDialogFile, setPermissionsDialogFile] =
|
||||
useState<FileItem | null>(null);
|
||||
const [compressDialogFiles, setCompressDialogFiles] = useState<FileItem[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
const { selectedFiles, clearSelection, setSelection } = useFileSelection();
|
||||
|
||||
@@ -527,41 +540,20 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(reader.error);
|
||||
|
||||
const isTextFile =
|
||||
file.type.startsWith("text/") ||
|
||||
file.type === "application/json" ||
|
||||
file.type === "application/javascript" ||
|
||||
file.type === "application/xml" ||
|
||||
file.type === "image/svg+xml" ||
|
||||
file.name.match(
|
||||
/\.(txt|json|js|ts|jsx|tsx|css|scss|less|html|htm|xml|svg|yaml|yml|md|markdown|mdown|mkdn|mdx|py|java|c|cpp|h|sh|bash|zsh|bat|ps1|toml|ini|conf|config|sql|vue|svelte)$/i,
|
||||
);
|
||||
|
||||
if (isTextFile) {
|
||||
reader.onload = () => {
|
||||
if (reader.result) {
|
||||
resolve(reader.result as string);
|
||||
} else {
|
||||
reject(new Error("Failed to read text file content"));
|
||||
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]);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
} 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(file);
|
||||
}
|
||||
const base64 = btoa(binary);
|
||||
resolve(base64);
|
||||
} else {
|
||||
reject(new Error("Failed to read file"));
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
|
||||
await uploadSSHFile(
|
||||
@@ -911,6 +903,26 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function handleCopyPath(files: FileItem[]) {
|
||||
if (files.length === 0) return;
|
||||
|
||||
const paths = files.map((file) => file.path).join("\n");
|
||||
|
||||
navigator.clipboard.writeText(paths).then(
|
||||
() => {
|
||||
toast.success(
|
||||
files.length === 1
|
||||
? t("fileManager.pathCopiedToClipboard")
|
||||
: t("fileManager.pathsCopiedToClipboard", { count: files.length }),
|
||||
);
|
||||
},
|
||||
(err) => {
|
||||
console.error("Failed to copy path to clipboard:", err);
|
||||
toast.error(t("fileManager.failedToCopyPath"));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function handlePasteFiles() {
|
||||
if (!clipboard || !sshSessionId) return;
|
||||
|
||||
@@ -1058,6 +1070,80 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExtractArchive(file: FileItem) {
|
||||
if (!sshSessionId) return;
|
||||
|
||||
try {
|
||||
await ensureSSHConnection();
|
||||
|
||||
toast.info(t("fileManager.extractingArchive", { name: file.name }));
|
||||
|
||||
await extractSSHArchive(
|
||||
sshSessionId,
|
||||
file.path,
|
||||
undefined,
|
||||
currentHost?.id,
|
||||
currentHost?.userId?.toString(),
|
||||
);
|
||||
|
||||
toast.success(
|
||||
t("fileManager.archiveExtractedSuccessfully", { name: file.name }),
|
||||
);
|
||||
|
||||
handleRefreshDirectory();
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
toast.error(
|
||||
`${t("fileManager.extractFailed")}: ${err.message || t("fileManager.unknownError")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenCompressDialog(files: FileItem[]) {
|
||||
setCompressDialogFiles(files);
|
||||
}
|
||||
|
||||
async function handleCompress(archiveName: string, format: string) {
|
||||
if (!sshSessionId || compressDialogFiles.length === 0) return;
|
||||
|
||||
try {
|
||||
await ensureSSHConnection();
|
||||
|
||||
const paths = compressDialogFiles.map((f) => f.path);
|
||||
const fileNames = compressDialogFiles.map((f) => f.name);
|
||||
|
||||
toast.info(
|
||||
t("fileManager.compressingFiles", {
|
||||
count: fileNames.length,
|
||||
name: archiveName,
|
||||
}),
|
||||
);
|
||||
|
||||
await compressSSHFiles(
|
||||
sshSessionId,
|
||||
paths,
|
||||
archiveName,
|
||||
format,
|
||||
currentHost?.id,
|
||||
currentHost?.userId?.toString(),
|
||||
);
|
||||
|
||||
toast.success(
|
||||
t("fileManager.filesCompressedSuccessfully", {
|
||||
name: archiveName,
|
||||
}),
|
||||
);
|
||||
|
||||
handleRefreshDirectory();
|
||||
clearSelection();
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
toast.error(
|
||||
`${t("fileManager.compressFailed")}: ${err.message || t("fileManager.unknownError")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUndo() {
|
||||
if (undoHistory.length === 0) {
|
||||
toast.info(t("fileManager.noUndoableActions"));
|
||||
@@ -1180,6 +1266,34 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
setEditingFile(file);
|
||||
}
|
||||
|
||||
function handleOpenPermissionsDialog(file: FileItem) {
|
||||
setPermissionsDialogFile(file);
|
||||
}
|
||||
|
||||
async function handleSavePermissions(file: FileItem, permissions: string) {
|
||||
if (!sshSessionId) {
|
||||
toast.error(t("fileManager.noSSHConnection"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await changeSSHPermissions(
|
||||
sshSessionId,
|
||||
file.path,
|
||||
permissions,
|
||||
currentHost?.id,
|
||||
currentHost?.userId?.toString(),
|
||||
);
|
||||
|
||||
toast.success(t("fileManager.permissionsChangedSuccessfully"));
|
||||
await handleRefreshDirectory();
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to change permissions:", error);
|
||||
toast.error(t("fileManager.failedToChangePermissions"));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureSSHConnection() {
|
||||
if (!sshSessionId || !currentHost || isReconnecting) return;
|
||||
|
||||
@@ -1775,6 +1889,12 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
}
|
||||
}, [currentHost?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("Saving viewMode to localStorage:", viewMode);
|
||||
localStorage.setItem("fileManagerViewMode", viewMode);
|
||||
console.log("Saved value:", localStorage.getItem("fileManagerViewMode"));
|
||||
}, [viewMode]);
|
||||
|
||||
const filteredFiles = files.filter((file) =>
|
||||
file.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
@@ -1928,6 +2048,8 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
createIntent={createIntent}
|
||||
onConfirmCreate={handleConfirmCreate}
|
||||
onCancelCreate={handleCancelCreate}
|
||||
onNewFile={handleCreateNewFile}
|
||||
onNewFolder={handleCreateNewFolder}
|
||||
/>
|
||||
|
||||
<FileManagerContextMenu
|
||||
@@ -1966,10 +2088,21 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
onAddShortcut={handleAddShortcut}
|
||||
isPinned={isPinnedFile}
|
||||
currentPath={currentPath}
|
||||
onProperties={handleOpenPermissionsDialog}
|
||||
onExtractArchive={handleExtractArchive}
|
||||
onCompress={handleOpenCompressDialog}
|
||||
onCopyPath={handleCopyPath}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CompressDialog
|
||||
open={compressDialogFiles.length > 0}
|
||||
onOpenChange={(open) => !open && setCompressDialogFiles([])}
|
||||
fileNames={compressDialogFiles.map((f) => f.name)}
|
||||
onCompress={handleCompress}
|
||||
/>
|
||||
|
||||
<TOTPDialog
|
||||
isOpen={totpRequired}
|
||||
prompt={totpPrompt}
|
||||
@@ -1991,6 +2124,15 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PermissionsDialog
|
||||
file={permissionsDialogFile}
|
||||
open={permissionsDialogFile !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPermissionsDialogFile(null);
|
||||
}}
|
||||
onSave={handleSavePermissions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,8 +17,10 @@ import {
|
||||
Play,
|
||||
Star,
|
||||
Bookmark,
|
||||
FileArchive,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Kbd, KbdGroup } from "@/components/ui/kbd";
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
@@ -59,6 +61,9 @@ interface ContextMenuProps {
|
||||
onAddShortcut?: (path: string) => void;
|
||||
isPinned?: (file: FileItem) => boolean;
|
||||
currentPath?: string;
|
||||
onExtractArchive?: (file: FileItem) => void;
|
||||
onCompress?: (files: FileItem[]) => void;
|
||||
onCopyPath?: (files: FileItem[]) => void;
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
@@ -98,12 +103,21 @@ export function FileManagerContextMenu({
|
||||
onAddShortcut,
|
||||
isPinned,
|
||||
currentPath,
|
||||
onExtractArchive,
|
||||
onCompress,
|
||||
onCopyPath,
|
||||
}: ContextMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const [menuPosition, setMenuPosition] = useState({ x, y });
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
if (!isVisible) {
|
||||
setIsMounted(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsMounted(true);
|
||||
|
||||
const adjustPosition = () => {
|
||||
const menuWidth = 200;
|
||||
@@ -182,8 +196,6 @@ export function FileManagerContextMenu({
|
||||
};
|
||||
}, [isVisible, x, y, onClose]);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
const isFileContext = files.length > 0;
|
||||
const isSingleFile = files.length === 1;
|
||||
const isMultipleFiles = files.length > 1;
|
||||
@@ -249,6 +261,43 @@ export function FileManagerContextMenu({
|
||||
});
|
||||
}
|
||||
|
||||
if (isSingleFile && files[0].type === "file" && onExtractArchive) {
|
||||
const fileName = files[0].name.toLowerCase();
|
||||
const isArchive =
|
||||
fileName.endsWith(".zip") ||
|
||||
fileName.endsWith(".tar") ||
|
||||
fileName.endsWith(".tar.gz") ||
|
||||
fileName.endsWith(".tgz") ||
|
||||
fileName.endsWith(".tar.bz2") ||
|
||||
fileName.endsWith(".tbz2") ||
|
||||
fileName.endsWith(".tar.xz") ||
|
||||
fileName.endsWith(".gz") ||
|
||||
fileName.endsWith(".bz2") ||
|
||||
fileName.endsWith(".xz") ||
|
||||
fileName.endsWith(".7z") ||
|
||||
fileName.endsWith(".rar");
|
||||
|
||||
if (isArchive) {
|
||||
menuItems.push({
|
||||
icon: <FileArchive className="w-4 h-4" />,
|
||||
label: t("fileManager.extractArchive"),
|
||||
action: () => onExtractArchive(files[0]),
|
||||
shortcut: "Ctrl+E",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isFileContext && onCompress) {
|
||||
menuItems.push({
|
||||
icon: <FileArchive className="w-4 h-4" />,
|
||||
label: isMultipleFiles
|
||||
? t("fileManager.compressFiles")
|
||||
: t("fileManager.compressFile"),
|
||||
action: () => onCompress(files),
|
||||
shortcut: "Ctrl+Shift+C",
|
||||
});
|
||||
}
|
||||
|
||||
if (isSingleFile && files[0].type === "file") {
|
||||
const isCurrentlyPinned = isPinned ? isPinned(files[0]) : false;
|
||||
|
||||
@@ -316,7 +365,30 @@ export function FileManagerContextMenu({
|
||||
});
|
||||
}
|
||||
|
||||
if ((isSingleFile && onRename) || onCopy || onCut) {
|
||||
if (onCopyPath) {
|
||||
menuItems.push({
|
||||
icon: <Clipboard className="w-4 h-4" />,
|
||||
label: isMultipleFiles
|
||||
? t("fileManager.copyPaths")
|
||||
: t("fileManager.copyPath"),
|
||||
action: () => onCopyPath(files),
|
||||
shortcut: "Ctrl+Shift+P",
|
||||
});
|
||||
}
|
||||
|
||||
if ((isSingleFile && onRename) || onCopy || onCut || onCopyPath) {
|
||||
menuItems.push({ separator: true } as MenuItem);
|
||||
}
|
||||
|
||||
if (isSingleFile && onProperties) {
|
||||
menuItems.push({
|
||||
icon: <Info className="w-4 h-4" />,
|
||||
label: t("fileManager.properties"),
|
||||
action: () => onProperties(files[0]),
|
||||
});
|
||||
}
|
||||
|
||||
if ((isSingleFile && onProperties) || onDelete) {
|
||||
menuItems.push({ separator: true } as MenuItem);
|
||||
}
|
||||
|
||||
@@ -331,18 +403,6 @@ export function FileManagerContextMenu({
|
||||
danger: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (onDelete) {
|
||||
menuItems.push({ separator: true } as MenuItem);
|
||||
}
|
||||
|
||||
if (isSingleFile && onProperties) {
|
||||
menuItems.push({
|
||||
icon: <Info className="w-4 h-4" />,
|
||||
label: t("fileManager.properties"),
|
||||
action: () => onProperties(files[0]),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (onOpenTerminal && currentPath) {
|
||||
menuItems.push({
|
||||
@@ -425,13 +485,36 @@ export function FileManagerContextMenu({
|
||||
return index > 0 && index < filteredMenuItems.length - 1;
|
||||
});
|
||||
|
||||
const renderShortcut = (shortcut: string) => {
|
||||
const keys = shortcut.split("+");
|
||||
if (keys.length === 1) {
|
||||
return <Kbd>{keys[0]}</Kbd>;
|
||||
}
|
||||
return (
|
||||
<KbdGroup>
|
||||
{keys.map((key, index) => (
|
||||
<Kbd key={index}>{key}</Kbd>
|
||||
))}
|
||||
</KbdGroup>
|
||||
);
|
||||
};
|
||||
|
||||
if (!isVisible && !isMounted) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[99990]" />
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-[99990] transition-opacity duration-150",
|
||||
!isMounted && "opacity-0",
|
||||
)}
|
||||
/>
|
||||
|
||||
<div
|
||||
data-context-menu
|
||||
className="fixed bg-dark-bg border border-dark-border rounded-lg shadow-xl min-w-[180px] max-w-[250px] z-[99995] overflow-hidden"
|
||||
className={cn(
|
||||
"fixed bg-dark-bg border border-dark-border rounded-lg shadow-xl min-w-[180px] max-w-[250px] z-[99995] overflow-hidden",
|
||||
)}
|
||||
style={{
|
||||
left: menuPosition.x,
|
||||
top: menuPosition.y,
|
||||
@@ -470,9 +553,9 @@ export function FileManagerContextMenu({
|
||||
<span className="flex-1">{item.label}</span>
|
||||
</div>
|
||||
{item.shortcut && (
|
||||
<span className="text-xs text-muted-foreground ml-2 flex-shrink-0">
|
||||
{item.shortcut}
|
||||
</span>
|
||||
<div className="ml-2 flex-shrink-0">
|
||||
{renderShortcut(item.shortcut)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { FileItem } from "../../../types/index.js";
|
||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||
|
||||
interface CreateIntent {
|
||||
id: string;
|
||||
@@ -92,17 +93,37 @@ interface FileManagerGridProps {
|
||||
createIntent?: CreateIntent | null;
|
||||
onConfirmCreate?: (name: string) => void;
|
||||
onCancelCreate?: () => void;
|
||||
onNewFile?: () => void;
|
||||
onNewFolder?: () => void;
|
||||
}
|
||||
|
||||
const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => {
|
||||
const iconClass = viewMode === "grid" ? "w-8 h-8" : "w-6 h-6";
|
||||
const getFileTypeColor = (file: FileItem): string => {
|
||||
const colorEnabled = localStorage.getItem("fileColorCoding") !== "false";
|
||||
if (!colorEnabled) {
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
|
||||
if (file.type === "directory") {
|
||||
return <Folder className={`${iconClass} text-muted-foreground`} />;
|
||||
return "text-red-400";
|
||||
}
|
||||
|
||||
if (file.type === "link") {
|
||||
return <FileSymlink className={`${iconClass} text-muted-foreground`} />;
|
||||
return "text-green-400";
|
||||
}
|
||||
|
||||
return "text-blue-400";
|
||||
};
|
||||
|
||||
const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => {
|
||||
const iconClass = viewMode === "grid" ? "w-8 h-8" : "w-6 h-6";
|
||||
const colorClass = getFileTypeColor(file);
|
||||
|
||||
if (file.type === "directory") {
|
||||
return <Folder className={`${iconClass} ${colorClass}`} />;
|
||||
}
|
||||
|
||||
if (file.type === "link") {
|
||||
return <FileSymlink className={`${iconClass} ${colorClass}`} />;
|
||||
}
|
||||
|
||||
const ext = file.name.split(".").pop()?.toLowerCase();
|
||||
@@ -111,30 +132,30 @@ const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => {
|
||||
case "txt":
|
||||
case "md":
|
||||
case "readme":
|
||||
return <FileText className={`${iconClass} text-muted-foreground`} />;
|
||||
return <FileText className={`${iconClass} ${colorClass}`} />;
|
||||
case "png":
|
||||
case "jpg":
|
||||
case "jpeg":
|
||||
case "gif":
|
||||
case "bmp":
|
||||
case "svg":
|
||||
return <FileImage className={`${iconClass} text-muted-foreground`} />;
|
||||
return <FileImage className={`${iconClass} ${colorClass}`} />;
|
||||
case "mp4":
|
||||
case "avi":
|
||||
case "mkv":
|
||||
case "mov":
|
||||
return <FileVideo className={`${iconClass} text-muted-foreground`} />;
|
||||
return <FileVideo className={`${iconClass} ${colorClass}`} />;
|
||||
case "mp3":
|
||||
case "wav":
|
||||
case "flac":
|
||||
case "ogg":
|
||||
return <FileAudio className={`${iconClass} text-muted-foreground`} />;
|
||||
return <FileAudio className={`${iconClass} ${colorClass}`} />;
|
||||
case "zip":
|
||||
case "tar":
|
||||
case "gz":
|
||||
case "rar":
|
||||
case "7z":
|
||||
return <Archive className={`${iconClass} text-muted-foreground`} />;
|
||||
return <Archive className={`${iconClass} ${colorClass}`} />;
|
||||
case "js":
|
||||
case "ts":
|
||||
case "jsx":
|
||||
@@ -148,7 +169,7 @@ const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => {
|
||||
case "rb":
|
||||
case "go":
|
||||
case "rs":
|
||||
return <Code className={`${iconClass} text-muted-foreground`} />;
|
||||
return <Code className={`${iconClass} ${colorClass}`} />;
|
||||
case "json":
|
||||
case "xml":
|
||||
case "yaml":
|
||||
@@ -157,9 +178,9 @@ const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => {
|
||||
case "ini":
|
||||
case "conf":
|
||||
case "config":
|
||||
return <Settings className={`${iconClass} text-muted-foreground`} />;
|
||||
return <Settings className={`${iconClass} ${colorClass}`} />;
|
||||
default:
|
||||
return <File className={`${iconClass} text-muted-foreground`} />;
|
||||
return <File className={`${iconClass} ${colorClass}`} />;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -192,6 +213,8 @@ export function FileManagerGrid({
|
||||
createIntent,
|
||||
onConfirmCreate,
|
||||
onCancelCreate,
|
||||
onNewFile,
|
||||
onNewFolder,
|
||||
}: FileManagerGridProps) {
|
||||
const { t } = useTranslation();
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
@@ -772,6 +795,42 @@ export function FileManagerGrid({
|
||||
onUndo();
|
||||
}
|
||||
break;
|
||||
case "d":
|
||||
case "D":
|
||||
if (
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
selectedFiles.length > 0 &&
|
||||
onDownload
|
||||
) {
|
||||
event.preventDefault();
|
||||
onDownload(selectedFiles);
|
||||
}
|
||||
break;
|
||||
case "n":
|
||||
case "N":
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault();
|
||||
if (event.shiftKey && onNewFolder) {
|
||||
onNewFolder();
|
||||
} else if (!event.shiftKey && onNewFile) {
|
||||
onNewFile();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "u":
|
||||
case "U":
|
||||
if ((event.ctrlKey || event.metaKey) && onUpload) {
|
||||
event.preventDefault();
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.multiple = true;
|
||||
input.onchange = (e) => {
|
||||
const files = (e.target as HTMLInputElement).files;
|
||||
if (files) onUpload(files);
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
break;
|
||||
case "Delete":
|
||||
if (selectedFiles.length > 0 && onDelete) {
|
||||
onDelete(selectedFiles);
|
||||
@@ -783,6 +842,12 @@ export function FileManagerGrid({
|
||||
onStartEdit(selectedFiles[0]);
|
||||
}
|
||||
break;
|
||||
case "Enter":
|
||||
if (selectedFiles.length === 1) {
|
||||
event.preventDefault();
|
||||
onFileOpen(selectedFiles[0]);
|
||||
}
|
||||
break;
|
||||
case "y":
|
||||
case "Y":
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
@@ -807,19 +872,8 @@ export function FileManagerGrid({
|
||||
onUndo,
|
||||
]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
|
||||
<p className="text-sm text-muted-foreground">{t("common.loading")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-dark-bg overflow-hidden">
|
||||
<div className="h-full flex flex-col bg-dark-bg overflow-hidden relative">
|
||||
<div className="flex-shrink-0 border-b border-dark-border">
|
||||
<div className="flex items-center gap-1 p-2 border-b border-dark-border">
|
||||
<button
|
||||
@@ -950,7 +1004,7 @@ export function FileManagerGrid({
|
||||
tabIndex={0}
|
||||
>
|
||||
{dragState.type === "external" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/50 backdrop-blur-sm z-10 pointer-events-none animate-in fade-in-0">
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/50 backdrop-blur-sm z-10 pointer-events-none">
|
||||
<div className="text-center p-8 bg-background/95 border-2 border-dashed border-primary rounded-lg shadow-lg">
|
||||
<Upload className="w-16 h-16 mx-auto mb-4 text-primary" />
|
||||
<p className="text-xl font-semibold text-foreground mb-2">
|
||||
@@ -1003,8 +1057,9 @@ export function FileManagerGrid({
|
||||
draggable={true}
|
||||
className={cn(
|
||||
"group p-3 rounded-lg cursor-pointer",
|
||||
"hover:bg-accent hover:text-accent-foreground border-2 border-transparent",
|
||||
isSelected && "bg-primary/20 border-primary",
|
||||
"hover:bg-accent hover:text-accent-foreground hover:scale-[1.02] border-2 border-transparent",
|
||||
isSelected &&
|
||||
"bg-primary/20 border-primary ring-2 ring-primary/20",
|
||||
dragState.target?.path === file.path &&
|
||||
"bg-muted border-primary border-dashed relative z-10",
|
||||
dragState.files.some((f) => f.path === file.path) &&
|
||||
@@ -1093,7 +1148,7 @@ export function FileManagerGrid({
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-2 rounded cursor-pointer",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
isSelected && "bg-primary/20",
|
||||
isSelected && "bg-primary/20 ring-2 ring-primary/20",
|
||||
dragState.target?.path === file.path &&
|
||||
"bg-muted border-primary border-dashed relative z-10",
|
||||
dragState.files.some((f) => f.path === file.path) &&
|
||||
@@ -1264,6 +1319,8 @@ export function FileManagerGrid({
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
<SimpleLoader visible={isLoading} message={t("common.loading")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
158
src/ui/desktop/apps/file-manager/components/CompressDialog.tsx
Normal file
158
src/ui/desktop/apps/file-manager/components/CompressDialog.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface CompressDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
fileNames: string[];
|
||||
onCompress: (archiveName: string, format: string) => void;
|
||||
}
|
||||
|
||||
export function CompressDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
fileNames,
|
||||
onCompress,
|
||||
}: CompressDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [archiveName, setArchiveName] = useState("");
|
||||
const [format, setFormat] = useState("zip");
|
||||
|
||||
useEffect(() => {
|
||||
if (open && fileNames.length > 0) {
|
||||
if (fileNames.length === 1) {
|
||||
const baseName = fileNames[0].replace(/\.[^/.]+$/, "");
|
||||
setArchiveName(baseName);
|
||||
} else {
|
||||
setArchiveName("archive");
|
||||
}
|
||||
}
|
||||
}, [open, fileNames]);
|
||||
|
||||
const handleCompress = () => {
|
||||
if (!archiveName.trim()) return;
|
||||
|
||||
let finalName = archiveName.trim();
|
||||
const extensions: Record<string, string> = {
|
||||
zip: ".zip",
|
||||
"tar.gz": ".tar.gz",
|
||||
"tar.bz2": ".tar.bz2",
|
||||
"tar.xz": ".tar.xz",
|
||||
tar: ".tar",
|
||||
"7z": ".7z",
|
||||
};
|
||||
|
||||
const expectedExtension = extensions[format];
|
||||
if (expectedExtension && !finalName.endsWith(expectedExtension)) {
|
||||
finalName += expectedExtension;
|
||||
}
|
||||
|
||||
onCompress(finalName, format);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px] bg-dark-bg border-2 border-dark-border">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("fileManager.compressFiles")}</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
{t("fileManager.compressFilesDesc", { count: fileNames.length })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
<div className="space-y-3">
|
||||
<Label
|
||||
className="text-base font-semibold text-foreground"
|
||||
htmlFor="archiveName"
|
||||
>
|
||||
{t("fileManager.archiveName")}
|
||||
</Label>
|
||||
<Input
|
||||
id="archiveName"
|
||||
value={archiveName}
|
||||
onChange={(e) => setArchiveName(e.target.value)}
|
||||
placeholder={t("fileManager.enterArchiveName")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleCompress();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label
|
||||
className="text-base font-semibold text-foreground"
|
||||
htmlFor="format"
|
||||
>
|
||||
{t("fileManager.compressionFormat")}
|
||||
</Label>
|
||||
<Select value={format} onValueChange={setFormat}>
|
||||
<SelectTrigger id="format">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="zip">ZIP (.zip)</SelectItem>
|
||||
<SelectItem value="tar.gz">TAR.GZ (.tar.gz)</SelectItem>
|
||||
<SelectItem value="tar.bz2">TAR.BZ2 (.tar.bz2)</SelectItem>
|
||||
<SelectItem value="tar.xz">TAR.XZ (.tar.xz)</SelectItem>
|
||||
<SelectItem value="tar">TAR (.tar)</SelectItem>
|
||||
<SelectItem value="7z">7-Zip (.7z)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md bg-dark-hover/50 border border-dark-border p-3">
|
||||
<p className="text-sm text-gray-400 mb-2">
|
||||
{t("fileManager.selectedFiles")}:
|
||||
</p>
|
||||
<ul className="text-sm space-y-1">
|
||||
{fileNames.slice(0, 5).map((name, index) => (
|
||||
<li key={index} className="truncate text-foreground">
|
||||
• {name}
|
||||
</li>
|
||||
))}
|
||||
{fileNames.length > 5 && (
|
||||
<li className="text-gray-400 italic">
|
||||
{t("fileManager.andMoreFiles", {
|
||||
count: fileNames.length - 5,
|
||||
})}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleCompress} disabled={!archiveName.trim()}>
|
||||
{t("fileManager.compress")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Shield } from "lucide-react";
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
type: "file" | "directory" | "link";
|
||||
path: string;
|
||||
permissions?: string;
|
||||
owner?: string;
|
||||
group?: string;
|
||||
}
|
||||
|
||||
interface PermissionsDialogProps {
|
||||
file: FileItem | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: (file: FileItem, permissions: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const parsePermissions = (
|
||||
perms: string,
|
||||
): { owner: number; group: number; other: number } => {
|
||||
if (!perms) {
|
||||
return { owner: 0, group: 0, other: 0 };
|
||||
}
|
||||
|
||||
if (/^\d{3,4}$/.test(perms)) {
|
||||
const numStr = perms.slice(-3);
|
||||
return {
|
||||
owner: parseInt(numStr[0] || "0", 10),
|
||||
group: parseInt(numStr[1] || "0", 10),
|
||||
other: parseInt(numStr[2] || "0", 10),
|
||||
};
|
||||
}
|
||||
const cleanPerms = perms.replace(/^-/, "").substring(0, 9);
|
||||
|
||||
const calcBits = (str: string): number => {
|
||||
let value = 0;
|
||||
if (str[0] === "r") value += 4;
|
||||
if (str[1] === "w") value += 2;
|
||||
if (str[2] === "x") value += 1;
|
||||
return value;
|
||||
};
|
||||
|
||||
return {
|
||||
owner: calcBits(cleanPerms.substring(0, 3)),
|
||||
group: calcBits(cleanPerms.substring(3, 6)),
|
||||
other: calcBits(cleanPerms.substring(6, 9)),
|
||||
};
|
||||
};
|
||||
|
||||
const toNumeric = (owner: number, group: number, other: number): string => {
|
||||
return `${owner}${group}${other}`;
|
||||
};
|
||||
|
||||
export function PermissionsDialog({
|
||||
file,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
}: PermissionsDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const initialPerms = parsePermissions(file?.permissions || "644");
|
||||
const [ownerRead, setOwnerRead] = useState((initialPerms.owner & 4) !== 0);
|
||||
const [ownerWrite, setOwnerWrite] = useState((initialPerms.owner & 2) !== 0);
|
||||
const [ownerExecute, setOwnerExecute] = useState(
|
||||
(initialPerms.owner & 1) !== 0,
|
||||
);
|
||||
|
||||
const [groupRead, setGroupRead] = useState((initialPerms.group & 4) !== 0);
|
||||
const [groupWrite, setGroupWrite] = useState((initialPerms.group & 2) !== 0);
|
||||
const [groupExecute, setGroupExecute] = useState(
|
||||
(initialPerms.group & 1) !== 0,
|
||||
);
|
||||
|
||||
const [otherRead, setOtherRead] = useState((initialPerms.other & 4) !== 0);
|
||||
const [otherWrite, setOtherWrite] = useState((initialPerms.other & 2) !== 0);
|
||||
const [otherExecute, setOtherExecute] = useState(
|
||||
(initialPerms.other & 1) !== 0,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (file) {
|
||||
const perms = parsePermissions(file.permissions || "644");
|
||||
setOwnerRead((perms.owner & 4) !== 0);
|
||||
setOwnerWrite((perms.owner & 2) !== 0);
|
||||
setOwnerExecute((perms.owner & 1) !== 0);
|
||||
setGroupRead((perms.group & 4) !== 0);
|
||||
setGroupWrite((perms.group & 2) !== 0);
|
||||
setGroupExecute((perms.group & 1) !== 0);
|
||||
setOtherRead((perms.other & 4) !== 0);
|
||||
setOtherWrite((perms.other & 2) !== 0);
|
||||
setOtherExecute((perms.other & 1) !== 0);
|
||||
}
|
||||
}, [file]);
|
||||
|
||||
const calculateOctal = (): string => {
|
||||
const owner =
|
||||
(ownerRead ? 4 : 0) + (ownerWrite ? 2 : 0) + (ownerExecute ? 1 : 0);
|
||||
const group =
|
||||
(groupRead ? 4 : 0) + (groupWrite ? 2 : 0) + (groupExecute ? 1 : 0);
|
||||
const other =
|
||||
(otherRead ? 4 : 0) + (otherWrite ? 2 : 0) + (otherExecute ? 1 : 0);
|
||||
return toNumeric(owner, group, other);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!file) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const permissions = calculateOctal();
|
||||
await onSave(file, permissions);
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to update permissions:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!file) return null;
|
||||
|
||||
const octal = calculateOctal();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px] bg-dark-bg border-2 border-dark-border">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5" />
|
||||
{t("fileManager.changePermissions")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
{t("fileManager.changePermissionsDesc")}:{" "}
|
||||
<span className="font-mono text-foreground">{file.name}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<Label className="text-gray-400">
|
||||
{t("fileManager.currentPermissions")}
|
||||
</Label>
|
||||
<p className="font-mono text-lg mt-1">
|
||||
{file.permissions || "644"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-gray-400">
|
||||
{t("fileManager.newPermissions")}
|
||||
</Label>
|
||||
<p className="font-mono text-lg mt-1">{octal}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold text-foreground">
|
||||
{t("fileManager.owner")} {file.owner && `(${file.owner})`}
|
||||
</Label>
|
||||
<div className="flex gap-6 ml-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="owner-read"
|
||||
checked={ownerRead}
|
||||
onCheckedChange={(checked) => setOwnerRead(checked === true)}
|
||||
/>
|
||||
<label htmlFor="owner-read" className="text-sm cursor-pointer">
|
||||
{t("fileManager.read")}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="owner-write"
|
||||
checked={ownerWrite}
|
||||
onCheckedChange={(checked) => setOwnerWrite(checked === true)}
|
||||
/>
|
||||
<label htmlFor="owner-write" className="text-sm cursor-pointer">
|
||||
{t("fileManager.write")}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="owner-execute"
|
||||
checked={ownerExecute}
|
||||
onCheckedChange={(checked) =>
|
||||
setOwnerExecute(checked === true)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="owner-execute"
|
||||
className="text-sm cursor-pointer"
|
||||
>
|
||||
{t("fileManager.execute")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold text-foreground">
|
||||
{t("fileManager.group")} {file.group && `(${file.group})`}
|
||||
</Label>
|
||||
<div className="flex gap-6 ml-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="group-read"
|
||||
checked={groupRead}
|
||||
onCheckedChange={(checked) => setGroupRead(checked === true)}
|
||||
/>
|
||||
<label htmlFor="group-read" className="text-sm cursor-pointer">
|
||||
{t("fileManager.read")}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="group-write"
|
||||
checked={groupWrite}
|
||||
onCheckedChange={(checked) => setGroupWrite(checked === true)}
|
||||
/>
|
||||
<label htmlFor="group-write" className="text-sm cursor-pointer">
|
||||
{t("fileManager.write")}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="group-execute"
|
||||
checked={groupExecute}
|
||||
onCheckedChange={(checked) =>
|
||||
setGroupExecute(checked === true)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="group-execute"
|
||||
className="text-sm cursor-pointer"
|
||||
>
|
||||
{t("fileManager.execute")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold text-foreground">
|
||||
{t("fileManager.others")}
|
||||
</Label>
|
||||
<div className="flex gap-6 ml-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="other-read"
|
||||
checked={otherRead}
|
||||
onCheckedChange={(checked) => setOtherRead(checked === true)}
|
||||
/>
|
||||
<label htmlFor="other-read" className="text-sm cursor-pointer">
|
||||
{t("fileManager.read")}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="other-write"
|
||||
checked={otherWrite}
|
||||
onCheckedChange={(checked) => setOtherWrite(checked === true)}
|
||||
/>
|
||||
<label htmlFor="other-write" className="text-sm cursor-pointer">
|
||||
{t("fileManager.write")}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="other-execute"
|
||||
checked={otherExecute}
|
||||
onCheckedChange={(checked) =>
|
||||
setOtherExecute(checked === true)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="other-execute"
|
||||
className="text-sm cursor-pointer"
|
||||
>
|
||||
{t("fileManager.execute")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={loading}>
|
||||
{loading ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
386
src/ui/desktop/apps/guacamole/GuacamoleDisplay.tsx
Normal file
386
src/ui/desktop/apps/guacamole/GuacamoleDisplay.tsx
Normal file
@@ -0,0 +1,386 @@
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useImperativeHandle,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import Guacamole from "guacamole-common-js";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { getCookie, isElectron } from "@/ui/main-axios.ts";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export type GuacamoleConnectionType = "rdp" | "vnc" | "telnet";
|
||||
|
||||
export interface GuacamoleConnectionConfig {
|
||||
// Pre-fetched token (preferred) - if provided, skip token fetch
|
||||
token?: string;
|
||||
protocol?: GuacamoleConnectionType;
|
||||
// Legacy fields for backward compatibility (used if token not provided)
|
||||
type?: GuacamoleConnectionType;
|
||||
hostname?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
domain?: string;
|
||||
// Display settings
|
||||
width?: number;
|
||||
height?: number;
|
||||
dpi?: number;
|
||||
// Additional protocol options
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface GuacamoleDisplayHandle {
|
||||
disconnect: () => void;
|
||||
sendKey: (keysym: number, pressed: boolean) => void;
|
||||
sendMouse: (x: number, y: number, buttonMask: number) => void;
|
||||
setClipboard: (data: string) => void;
|
||||
}
|
||||
|
||||
interface GuacamoleDisplayProps {
|
||||
connectionConfig: GuacamoleConnectionConfig;
|
||||
isVisible: boolean;
|
||||
onConnect?: () => void;
|
||||
onDisconnect?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
const isDev = import.meta.env.DEV;
|
||||
|
||||
export const GuacamoleDisplay = forwardRef<GuacamoleDisplayHandle, GuacamoleDisplayProps>(
|
||||
function GuacamoleDisplay(
|
||||
{ connectionConfig, isVisible, onConnect, onDisconnect, onError },
|
||||
ref
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const containerRef = useRef<HTMLDivElement>(null); // Outer container for measuring size
|
||||
const displayRef = useRef<HTMLDivElement>(null); // Inner div for guacamole canvas
|
||||
const clientRef = useRef<Guacamole.Client | null>(null);
|
||||
const scaleRef = useRef<number>(1); // Track current scale factor for mouse
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
disconnect: () => {
|
||||
if (clientRef.current) {
|
||||
clientRef.current.disconnect();
|
||||
}
|
||||
},
|
||||
sendKey: (keysym: number, pressed: boolean) => {
|
||||
if (clientRef.current) {
|
||||
clientRef.current.sendKeyEvent(pressed ? 1 : 0, keysym);
|
||||
}
|
||||
},
|
||||
sendMouse: (x: number, y: number, buttonMask: number) => {
|
||||
if (clientRef.current) {
|
||||
clientRef.current.sendMouseState(
|
||||
new Guacamole.Mouse.State({ x, y, left: !!(buttonMask & 1), middle: !!(buttonMask & 2), right: !!(buttonMask & 4) })
|
||||
);
|
||||
}
|
||||
},
|
||||
setClipboard: (data: string) => {
|
||||
if (clientRef.current) {
|
||||
const stream = clientRef.current.createClipboardStream("text/plain");
|
||||
const writer = new Guacamole.StringWriter(stream);
|
||||
writer.sendText(data);
|
||||
writer.sendEnd();
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const getWebSocketUrl = useCallback(async (containerWidth: number, containerHeight: number): Promise<string | null> => {
|
||||
try {
|
||||
let token: string;
|
||||
|
||||
// If token is pre-fetched, use it directly
|
||||
if (connectionConfig.token) {
|
||||
token = connectionConfig.token;
|
||||
} else {
|
||||
// Otherwise, fetch token from backend (legacy behavior)
|
||||
const jwtToken = getCookie("jwt");
|
||||
if (!jwtToken) {
|
||||
setConnectionError("Authentication required");
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseUrl = isDev
|
||||
? "http://localhost:30001"
|
||||
: isElectron()
|
||||
? (window as { configuredServerUrl?: string }).configuredServerUrl || "http://127.0.0.1:30001"
|
||||
: `${window.location.origin}`;
|
||||
|
||||
const response = await fetch(`${baseUrl}/guacamole/token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${jwtToken}`,
|
||||
},
|
||||
body: JSON.stringify(connectionConfig),
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.error || "Failed to get connection token");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
token = data.token;
|
||||
}
|
||||
|
||||
// Build WebSocket URL with width/height/dpi as query parameters
|
||||
// These are passed as unencrypted settings to guacamole-lite
|
||||
// Use actual container dimensions, fall back to 720p
|
||||
const width = connectionConfig.width || containerWidth || 1280;
|
||||
const height = connectionConfig.height || containerHeight || 720;
|
||||
const dpi = connectionConfig.dpi || 96;
|
||||
|
||||
const wsBase = isDev
|
||||
? `ws://localhost:30007`
|
||||
: isElectron()
|
||||
? (() => {
|
||||
const base = (window as { configuredServerUrl?: string }).configuredServerUrl || "http://127.0.0.1:30001";
|
||||
return `${base.startsWith("https://") ? "wss://" : "ws://"}${base.replace(/^https?:\/\//, "")}/guacamole/websocket/`;
|
||||
})()
|
||||
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/guacamole/websocket/`;
|
||||
|
||||
return `${wsBase}?token=${encodeURIComponent(token)}&width=${width}&height=${height}&dpi=${dpi}`;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
setConnectionError(errorMessage);
|
||||
onError?.(errorMessage);
|
||||
return null;
|
||||
}
|
||||
}, [connectionConfig, onError]);
|
||||
|
||||
const connect = useCallback(async () => {
|
||||
if (isConnecting || isConnected) return;
|
||||
setIsConnecting(true);
|
||||
setConnectionError(null);
|
||||
|
||||
// Get container dimensions for the WebSocket URL
|
||||
// Use the outer container ref which has h-full w-full
|
||||
let containerWidth = containerRef.current?.clientWidth || 0;
|
||||
let containerHeight = containerRef.current?.clientHeight || 0;
|
||||
|
||||
console.log(`[Guacamole] Container size: ${containerWidth}x${containerHeight}`);
|
||||
|
||||
// If container size is too small or unavailable, use 720p default
|
||||
if (containerWidth < 100 || containerHeight < 100) {
|
||||
console.log(`[Guacamole] Container too small, using 720p default`);
|
||||
containerWidth = 1280;
|
||||
containerHeight = 720;
|
||||
}
|
||||
|
||||
const wsUrl = await getWebSocketUrl(containerWidth, containerHeight);
|
||||
if (!wsUrl) {
|
||||
setIsConnecting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const tunnel = new Guacamole.WebSocketTunnel(wsUrl);
|
||||
const client = new Guacamole.Client(tunnel);
|
||||
clientRef.current = client;
|
||||
|
||||
// Set up display
|
||||
const display = client.getDisplay();
|
||||
const displayElement = display.getElement();
|
||||
|
||||
if (displayRef.current) {
|
||||
displayRef.current.innerHTML = "";
|
||||
displayRef.current.appendChild(displayElement);
|
||||
}
|
||||
|
||||
// Function to rescale display to fit container
|
||||
const rescaleDisplay = () => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const cWidth = containerRef.current.clientWidth;
|
||||
const cHeight = containerRef.current.clientHeight;
|
||||
const displayWidth = display.getWidth();
|
||||
const displayHeight = display.getHeight();
|
||||
|
||||
if (displayWidth > 0 && displayHeight > 0 && cWidth > 0 && cHeight > 0) {
|
||||
const scale = Math.min(cWidth / displayWidth, cHeight / displayHeight);
|
||||
scaleRef.current = scale;
|
||||
display.scale(scale);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle display sync (when frames arrive)
|
||||
display.onresize = () => {
|
||||
rescaleDisplay();
|
||||
};
|
||||
|
||||
// Set up mouse input on the display element (not the container)
|
||||
// We need to adjust mouse coordinates based on the current scale factor
|
||||
const mouse = new Guacamole.Mouse(displayElement);
|
||||
const sendMouseState = (state: Guacamole.Mouse.State) => {
|
||||
// Adjust coordinates based on scale factor and round to integers
|
||||
const scale = scaleRef.current;
|
||||
const adjustedX = Math.round(state.x / scale);
|
||||
const adjustedY = Math.round(state.y / scale);
|
||||
|
||||
// Create adjusted state - guacamole expects integer coordinates
|
||||
const adjustedState = new Guacamole.Mouse.State(
|
||||
adjustedX,
|
||||
adjustedY,
|
||||
state.left,
|
||||
state.middle,
|
||||
state.right,
|
||||
state.up,
|
||||
state.down
|
||||
) as Guacamole.Mouse.State;
|
||||
|
||||
client.sendMouseState(adjustedState);
|
||||
};
|
||||
mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = sendMouseState;
|
||||
|
||||
// Set up keyboard input
|
||||
const keyboard = new Guacamole.Keyboard(document);
|
||||
keyboard.onkeydown = (keysym: number) => {
|
||||
client.sendKeyEvent(1, keysym);
|
||||
};
|
||||
keyboard.onkeyup = (keysym: number) => {
|
||||
client.sendKeyEvent(0, keysym);
|
||||
};
|
||||
|
||||
// Handle client state changes
|
||||
client.onstatechange = (state: number) => {
|
||||
switch (state) {
|
||||
case 0: // IDLE
|
||||
break;
|
||||
case 1: // CONNECTING
|
||||
setIsConnecting(true);
|
||||
break;
|
||||
case 2: // WAITING
|
||||
break;
|
||||
case 3: // CONNECTED
|
||||
setIsConnected(true);
|
||||
setIsConnecting(false);
|
||||
onConnect?.();
|
||||
break;
|
||||
case 4: // DISCONNECTING
|
||||
break;
|
||||
case 5: // DISCONNECTED
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
keyboard.onkeydown = null;
|
||||
keyboard.onkeyup = null;
|
||||
onDisconnect?.();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle errors
|
||||
client.onerror = (error: Guacamole.Status) => {
|
||||
const errorMessage = error.message || "Connection error";
|
||||
setConnectionError(errorMessage);
|
||||
setIsConnecting(false);
|
||||
onError?.(errorMessage);
|
||||
toast.error(`${t("guacamole.connectionError")}: ${errorMessage}`);
|
||||
};
|
||||
|
||||
// Handle clipboard from remote
|
||||
client.onclipboard = (stream: Guacamole.InputStream, mimetype: string) => {
|
||||
if (mimetype === "text/plain") {
|
||||
const reader = new Guacamole.StringReader(stream);
|
||||
let data = "";
|
||||
reader.ontext = (text: string) => {
|
||||
data += text;
|
||||
};
|
||||
reader.onend = () => {
|
||||
navigator.clipboard.writeText(data).catch(() => {});
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Connect - the width/height/dpi are already in the WebSocket URL
|
||||
client.connect();
|
||||
}, [isConnecting, isConnected, getWebSocketUrl, connectionConfig, onConnect, onDisconnect, onError, t]);
|
||||
|
||||
// Track if we've initiated a connection to prevent re-triggering
|
||||
const hasInitiatedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible && !hasInitiatedRef.current) {
|
||||
hasInitiatedRef.current = true;
|
||||
connect();
|
||||
}
|
||||
}, [isVisible, connect]);
|
||||
|
||||
// Separate cleanup effect that only runs on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (clientRef.current) {
|
||||
clientRef.current.disconnect();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle window resize - rescale display to fit container
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (clientRef.current && containerRef.current) {
|
||||
const display = clientRef.current.getDisplay();
|
||||
const cWidth = containerRef.current.clientWidth;
|
||||
const cHeight = containerRef.current.clientHeight;
|
||||
const displayWidth = display.getWidth();
|
||||
const displayHeight = display.getHeight();
|
||||
|
||||
if (displayWidth > 0 && displayHeight > 0 && cWidth > 0 && cHeight > 0) {
|
||||
const scale = Math.min(cWidth / displayWidth, cHeight / displayHeight);
|
||||
scaleRef.current = scale;
|
||||
display.scale(scale);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
// Also trigger on initial render after a short delay
|
||||
const initialTimeout = setTimeout(handleResize, 100);
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
clearTimeout(initialTimeout);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full w-full relative bg-black flex items-center justify-center overflow-hidden"
|
||||
>
|
||||
<div
|
||||
ref={displayRef}
|
||||
className="relative"
|
||||
style={{ cursor: isConnected ? "none" : "default" }}
|
||||
/>
|
||||
|
||||
{isConnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/80">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
<span className="text-muted-foreground">
|
||||
{t("guacamole.connecting", { type: (connectionConfig.protocol || connectionConfig.type || "remote").toUpperCase() })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connectionError && !isConnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/80">
|
||||
<div className="flex flex-col items-center gap-4 text-center p-4">
|
||||
<span className="text-destructive font-medium">{t("guacamole.connectionFailed")}</span>
|
||||
<span className="text-muted-foreground text-sm">{connectionError}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -18,6 +18,8 @@ export function HostManager({
|
||||
isTopbarOpen,
|
||||
initialTab = "host_viewer",
|
||||
hostConfig,
|
||||
rightSidebarOpen = false,
|
||||
rightSidebarWidth = 400,
|
||||
}: HostManagerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState(initialTab);
|
||||
@@ -35,28 +37,19 @@ export function HostManager({
|
||||
const lastProcessedHostIdRef = useRef<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (ignoreNextHostConfigChangeRef.current) {
|
||||
ignoreNextHostConfigChangeRef.current = false;
|
||||
return;
|
||||
if (initialTab) {
|
||||
setActiveTab(initialTab);
|
||||
}
|
||||
}, [initialTab]);
|
||||
|
||||
if (hostConfig && initialTab === "add_host") {
|
||||
const currentHostId = hostConfig.id;
|
||||
|
||||
if (currentHostId !== lastProcessedHostIdRef.current) {
|
||||
setEditingHost(hostConfig);
|
||||
setActiveTab("add_host");
|
||||
lastProcessedHostIdRef.current = currentHostId;
|
||||
} else if (
|
||||
activeTab === "host_viewer" ||
|
||||
activeTab === "credentials" ||
|
||||
activeTab === "add_credential"
|
||||
) {
|
||||
setEditingHost(hostConfig);
|
||||
setActiveTab("add_host");
|
||||
}
|
||||
// Update editingHost when hostConfig changes
|
||||
useEffect(() => {
|
||||
if (hostConfig) {
|
||||
setEditingHost(hostConfig);
|
||||
setActiveTab("add_host");
|
||||
lastProcessedHostIdRef.current = hostConfig.id;
|
||||
}
|
||||
}, [hostConfig, initialTab]);
|
||||
}, [hostConfig?.id]);
|
||||
|
||||
const handleEditHost = (host: SSHHost) => {
|
||||
setEditingHost(host);
|
||||
@@ -88,13 +81,13 @@ export function HostManager({
|
||||
};
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value);
|
||||
if (value !== "add_host") {
|
||||
if (activeTab === "add_host" && value !== "add_host") {
|
||||
setEditingHost(null);
|
||||
}
|
||||
if (value !== "add_credential") {
|
||||
if (activeTab === "add_credential" && value !== "add_credential") {
|
||||
setEditingCredential(null);
|
||||
}
|
||||
setActiveTab(value);
|
||||
};
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||
@@ -108,10 +101,14 @@ export function HostManager({
|
||||
className="bg-dark-bg text-white p-4 pt-0 rounded-lg border-2 border-dark-border flex flex-col min-h-0 overflow-hidden"
|
||||
style={{
|
||||
marginLeft: leftMarginPx,
|
||||
marginRight: 17,
|
||||
marginRight: rightSidebarOpen
|
||||
? `calc(var(--right-sidebar-width, ${rightSidebarWidth}px) + 8px)`
|
||||
: 17,
|
||||
marginTop: topMarginPx,
|
||||
marginBottom: bottomMarginPx,
|
||||
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
|
||||
transition:
|
||||
"margin-left 200ms linear, margin-right 200ms linear, margin-top 200ms linear",
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,10 +22,15 @@ import {
|
||||
updateSSHHost,
|
||||
renameFolder,
|
||||
exportSSHHostWithCredentials,
|
||||
getSSHFolders,
|
||||
updateFolderMetadata,
|
||||
deleteAllHostsInFolder,
|
||||
getServerStatusById,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import { Status, StatusIndicator } from "@/components/ui/shadcn-io/status";
|
||||
import {
|
||||
Edit,
|
||||
Trash2,
|
||||
@@ -45,16 +50,34 @@ import {
|
||||
Copy,
|
||||
Activity,
|
||||
Clock,
|
||||
Palette,
|
||||
Trash,
|
||||
Cloud,
|
||||
Database,
|
||||
Box,
|
||||
Package,
|
||||
Layers,
|
||||
Archive,
|
||||
HardDrive,
|
||||
Globe,
|
||||
FolderOpen,
|
||||
Monitor,
|
||||
ScreenShare,
|
||||
} from "lucide-react";
|
||||
import { getGuacamoleToken } from "@/ui/main-axios.ts";
|
||||
import type {
|
||||
SSHHost,
|
||||
SSHFolder,
|
||||
SSHManagerHostViewerProps,
|
||||
} from "../../../../types/index.js";
|
||||
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
|
||||
import { FolderEditDialog } from "./components/FolderEditDialog";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext";
|
||||
|
||||
export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
const { addTab } = useTabs();
|
||||
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -65,23 +88,38 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
const [editingFolder, setEditingFolder] = useState<string | null>(null);
|
||||
const [editingFolderName, setEditingFolderName] = useState("");
|
||||
const [operationLoading, setOperationLoading] = useState(false);
|
||||
const [folderMetadata, setFolderMetadata] = useState<Map<string, SSHFolder>>(
|
||||
new Map(),
|
||||
);
|
||||
const [editingFolderAppearance, setEditingFolderAppearance] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [serverStatuses, setServerStatuses] = useState<
|
||||
Map<number, "online" | "offline" | "degraded">
|
||||
>(new Map());
|
||||
const dragCounter = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
fetchHosts();
|
||||
fetchFolderMetadata();
|
||||
|
||||
const handleHostsRefresh = () => {
|
||||
fetchHosts();
|
||||
fetchFolderMetadata();
|
||||
};
|
||||
|
||||
const handleFoldersRefresh = () => {
|
||||
fetchFolderMetadata();
|
||||
};
|
||||
|
||||
window.addEventListener("hosts:refresh", handleHostsRefresh);
|
||||
window.addEventListener("ssh-hosts:changed", handleHostsRefresh);
|
||||
window.addEventListener("folders:changed", handleHostsRefresh);
|
||||
window.addEventListener("folders:changed", handleFoldersRefresh);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("hosts:refresh", handleHostsRefresh);
|
||||
window.removeEventListener("ssh-hosts:changed", handleHostsRefresh);
|
||||
window.removeEventListener("folders:changed", handleHostsRefresh);
|
||||
window.removeEventListener("folders:changed", handleFoldersRefresh);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -116,6 +154,156 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchFolderMetadata = async () => {
|
||||
try {
|
||||
const folders = await getSSHFolders();
|
||||
const metadataMap = new Map<string, SSHFolder>();
|
||||
folders.forEach((folder) => {
|
||||
metadataMap.set(folder.name, folder);
|
||||
});
|
||||
setFolderMetadata(metadataMap);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch folder metadata:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveFolderAppearance = async (
|
||||
folderName: string,
|
||||
color: string,
|
||||
icon: string,
|
||||
) => {
|
||||
try {
|
||||
await updateFolderMetadata(folderName, color, icon);
|
||||
toast.success(t("hosts.folderAppearanceUpdated"));
|
||||
await fetchFolderMetadata();
|
||||
window.dispatchEvent(new CustomEvent("folders:changed"));
|
||||
} catch (error) {
|
||||
console.error("Failed to update folder appearance:", error);
|
||||
toast.error(t("hosts.failedToUpdateFolderAppearance"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAllHostsInFolder = async (folderName: string) => {
|
||||
const hostsInFolder = hostsByFolder[folderName] || [];
|
||||
confirmWithToast(
|
||||
t("hosts.confirmDeleteAllHostsInFolder", {
|
||||
folder: folderName,
|
||||
count: hostsInFolder.length,
|
||||
}),
|
||||
async () => {
|
||||
try {
|
||||
const result = await deleteAllHostsInFolder(folderName);
|
||||
toast.success(
|
||||
t("hosts.allHostsInFolderDeleted", {
|
||||
folder: folderName,
|
||||
count: result.deletedCount,
|
||||
}),
|
||||
);
|
||||
await fetchHosts();
|
||||
await fetchFolderMetadata();
|
||||
window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
|
||||
} catch (error) {
|
||||
console.error("Failed to delete hosts in folder:", error);
|
||||
toast.error(t("hosts.failedToDeleteHostsInFolder"));
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (hosts.length === 0) return;
|
||||
|
||||
const statusIntervals: NodeJS.Timeout[] = [];
|
||||
const statusCancelled: boolean[] = [];
|
||||
|
||||
hosts.forEach((host, index) => {
|
||||
const statsConfig = (() => {
|
||||
try {
|
||||
return host.statsConfig
|
||||
? JSON.parse(host.statsConfig)
|
||||
: DEFAULT_STATS_CONFIG;
|
||||
} catch {
|
||||
return DEFAULT_STATS_CONFIG;
|
||||
}
|
||||
})();
|
||||
|
||||
const shouldShowStatus = statsConfig.statusCheckEnabled !== false;
|
||||
|
||||
if (!shouldShowStatus) {
|
||||
setServerStatuses((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(host.id, "offline");
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const res = await getServerStatusById(host.id);
|
||||
if (!statusCancelled[index]) {
|
||||
setServerStatuses((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(
|
||||
host.id,
|
||||
res?.status === "online" ? "online" : "offline",
|
||||
);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (!statusCancelled[index]) {
|
||||
const err = error as { response?: { status?: number } };
|
||||
let status: "online" | "offline" | "degraded" = "offline";
|
||||
if (err?.response?.status === 504) {
|
||||
status = "degraded";
|
||||
}
|
||||
setServerStatuses((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(host.id, status);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchStatus();
|
||||
const intervalId = setInterval(fetchStatus, 10000);
|
||||
statusIntervals.push(intervalId);
|
||||
});
|
||||
|
||||
return () => {
|
||||
statusCancelled.fill(true);
|
||||
statusIntervals.forEach((interval) => clearInterval(interval));
|
||||
};
|
||||
}, [hosts]);
|
||||
|
||||
const getFolderIcon = (folderName: string) => {
|
||||
const metadata = folderMetadata.get(folderName);
|
||||
if (!metadata?.icon) return Folder;
|
||||
|
||||
const iconMap: Record<string, React.ComponentType> = {
|
||||
Folder,
|
||||
Server,
|
||||
Cloud,
|
||||
Database,
|
||||
Box,
|
||||
Package,
|
||||
Layers,
|
||||
Archive,
|
||||
HardDrive,
|
||||
Globe,
|
||||
};
|
||||
|
||||
return iconMap[metadata.icon] || Folder;
|
||||
};
|
||||
|
||||
const getFolderColor = (folderName: string) => {
|
||||
const metadata = folderMetadata.get(folderName);
|
||||
return metadata?.color;
|
||||
};
|
||||
|
||||
const handleDelete = async (hostId: number, hostName: string) => {
|
||||
confirmWithToast(
|
||||
t("hosts.confirmDelete", { name: hostName }),
|
||||
@@ -854,7 +1042,18 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
<AccordionItem value={folder} className="border-none">
|
||||
<AccordionTrigger className="px-2 py-1 bg-muted/20 border-b hover:no-underline rounded-t-md">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<Folder className="h-4 w-4" />
|
||||
{(() => {
|
||||
const FolderIcon = getFolderIcon(folder);
|
||||
const folderColor = getFolderColor(folder);
|
||||
return (
|
||||
<FolderIcon
|
||||
className="h-4 w-4"
|
||||
style={
|
||||
folderColor ? { color: folderColor } : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
{editingFolder === folder ? (
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
@@ -935,6 +1134,50 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{folderHosts.length}
|
||||
</Badge>
|
||||
{folder !== t("hosts.uncategorized") && (
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingFolderAppearance(folder);
|
||||
}}
|
||||
className="h-6 w-6 p-0 opacity-50 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Palette className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("hosts.editFolderAppearance")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteAllHostsInFolder(folder);
|
||||
}}
|
||||
className="h-6 w-6 p-0 opacity-50 hover:opacity-100 hover:text-red-400 transition-all"
|
||||
>
|
||||
<Trash className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("hosts.deleteAllHostsInFolder")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="p-2">
|
||||
@@ -957,6 +1200,32 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
{(() => {
|
||||
const statsConfig = (() => {
|
||||
try {
|
||||
return host.statsConfig
|
||||
? JSON.parse(host.statsConfig)
|
||||
: DEFAULT_STATS_CONFIG;
|
||||
} catch {
|
||||
return DEFAULT_STATS_CONFIG;
|
||||
}
|
||||
})();
|
||||
const shouldShowStatus =
|
||||
statsConfig.statusCheckEnabled !==
|
||||
false;
|
||||
const serverStatus =
|
||||
serverStatuses.get(host.id) ||
|
||||
"degraded";
|
||||
|
||||
return shouldShowStatus ? (
|
||||
<Status
|
||||
status={serverStatus}
|
||||
className="!bg-transparent !p-0.75 flex-shrink-0"
|
||||
>
|
||||
<StatusIndicator />
|
||||
</Status>
|
||||
) : null;
|
||||
})()}
|
||||
{host.pin && (
|
||||
<Pin className="h-3 w-3 text-yellow-500 flex-shrink-0" />
|
||||
)}
|
||||
@@ -971,6 +1240,9 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{host.username}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
ID: {host.id}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1 flex-shrink-0 ml-1">
|
||||
{host.folder && host.folder !== "" && (
|
||||
@@ -1102,7 +1374,28 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{host.enableTerminal && (
|
||||
{/* Show connection type badge */}
|
||||
{(host.connectionType === "rdp" || host.connectionType === "vnc") ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs px-1 py-0"
|
||||
>
|
||||
{host.connectionType === "rdp" ? (
|
||||
<Monitor className="h-2 w-2 mr-0.5" />
|
||||
) : (
|
||||
<ScreenShare className="h-2 w-2 mr-0.5" />
|
||||
)}
|
||||
{host.connectionType.toUpperCase()}
|
||||
</Badge>
|
||||
) : host.connectionType === "telnet" ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs px-1 py-0"
|
||||
>
|
||||
<Terminal className="h-2 w-2 mr-0.5" />
|
||||
Telnet
|
||||
</Badge>
|
||||
) : host.enableTerminal && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs px-1 py-0"
|
||||
@@ -1179,6 +1472,130 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 pt-3 border-t border-border/50 flex items-center justify-center gap-1">
|
||||
{/* Show connect button for SSH/Telnet if enableTerminal, or always for RDP/VNC */}
|
||||
{(host.enableTerminal || host.connectionType === "rdp" || host.connectionType === "vnc") && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
const title = host.name?.trim()
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
const connectionType = host.connectionType || "ssh";
|
||||
|
||||
if (connectionType === "ssh" || connectionType === "telnet") {
|
||||
addTab({
|
||||
type: "terminal",
|
||||
title,
|
||||
hostConfig: host,
|
||||
});
|
||||
} else if (connectionType === "rdp" || connectionType === "vnc") {
|
||||
try {
|
||||
// Parse guacamoleConfig if it's a string
|
||||
const guacConfig = typeof host.guacamoleConfig === "string"
|
||||
? JSON.parse(host.guacamoleConfig)
|
||||
: host.guacamoleConfig;
|
||||
|
||||
const tokenResponse = await getGuacamoleToken({
|
||||
protocol: connectionType,
|
||||
hostname: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
password: host.password || "",
|
||||
domain: host.domain,
|
||||
security: host.security,
|
||||
ignoreCert: host.ignoreCert,
|
||||
guacamoleConfig: guacConfig,
|
||||
});
|
||||
addTab({
|
||||
type: connectionType,
|
||||
title,
|
||||
hostConfig: host,
|
||||
connectionConfig: {
|
||||
token: tokenResponse.token,
|
||||
protocol: connectionType,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Failed to get guacamole token for ${connectionType}:`, error);
|
||||
toast.error(`Failed to connect to ${connectionType.toUpperCase()} host`);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="h-7 px-2 hover:bg-blue-500/10 hover:border-blue-500/50 flex-1"
|
||||
>
|
||||
{host.connectionType === "rdp" ? (
|
||||
<Monitor className="h-3.5 w-3.5" />
|
||||
) : host.connectionType === "vnc" ? (
|
||||
<ScreenShare className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{host.connectionType === "rdp" ? "Open RDP" : host.connectionType === "vnc" ? "Open VNC" : "Open Terminal"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{host.enableFileManager && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const title = host.name?.trim()
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
addTab({
|
||||
type: "file_manager",
|
||||
title,
|
||||
hostConfig: host,
|
||||
});
|
||||
}}
|
||||
className="h-7 px-2 hover:bg-emerald-500/10 hover:border-emerald-500/50 flex-1"
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Open File Manager</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const title = host.name?.trim()
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
addTab({
|
||||
type: "server",
|
||||
title,
|
||||
hostConfig: host,
|
||||
});
|
||||
}}
|
||||
className="h-7 px-2 hover:bg-purple-500/10 hover:border-purple-500/50 flex-1"
|
||||
>
|
||||
<Server className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Open Server Details</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@@ -1202,6 +1619,26 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{editingFolderAppearance && (
|
||||
<FolderEditDialog
|
||||
folderName={editingFolderAppearance}
|
||||
currentColor={getFolderColor(editingFolderAppearance)}
|
||||
currentIcon={folderMetadata.get(editingFolderAppearance)?.icon}
|
||||
open={editingFolderAppearance !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditingFolderAppearance(null);
|
||||
}}
|
||||
onSave={async (color, icon) => {
|
||||
await handleSaveFolderAppearance(
|
||||
editingFolderAppearance,
|
||||
color,
|
||||
icon,
|
||||
);
|
||||
setEditingFolderAppearance(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
191
src/ui/desktop/apps/host-manager/components/FolderEditDialog.tsx
Normal file
191
src/ui/desktop/apps/host-manager/components/FolderEditDialog.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Folder,
|
||||
Server,
|
||||
Cloud,
|
||||
Database,
|
||||
Box,
|
||||
Package,
|
||||
Layers,
|
||||
Archive,
|
||||
HardDrive,
|
||||
Globe,
|
||||
} from "lucide-react";
|
||||
|
||||
interface FolderEditDialogProps {
|
||||
folderName: string;
|
||||
currentColor?: string;
|
||||
currentIcon?: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: (color: string, icon: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const AVAILABLE_COLORS = [
|
||||
{ value: "#ef4444", label: "Red" },
|
||||
{ value: "#f97316", label: "Orange" },
|
||||
{ value: "#eab308", label: "Yellow" },
|
||||
{ value: "#22c55e", label: "Green" },
|
||||
{ value: "#3b82f6", label: "Blue" },
|
||||
{ value: "#a855f7", label: "Purple" },
|
||||
{ value: "#ec4899", label: "Pink" },
|
||||
{ value: "#6b7280", label: "Gray" },
|
||||
];
|
||||
|
||||
const AVAILABLE_ICONS = [
|
||||
{ value: "Folder", label: "Folder", Icon: Folder },
|
||||
{ value: "Server", label: "Server", Icon: Server },
|
||||
{ value: "Cloud", label: "Cloud", Icon: Cloud },
|
||||
{ value: "Database", label: "Database", Icon: Database },
|
||||
{ value: "Box", label: "Box", Icon: Box },
|
||||
{ value: "Package", label: "Package", Icon: Package },
|
||||
{ value: "Layers", label: "Layers", Icon: Layers },
|
||||
{ value: "Archive", label: "Archive", Icon: Archive },
|
||||
{ value: "HardDrive", label: "HardDrive", Icon: HardDrive },
|
||||
{ value: "Globe", label: "Globe", Icon: Globe },
|
||||
];
|
||||
|
||||
export function FolderEditDialog({
|
||||
folderName,
|
||||
currentColor,
|
||||
currentIcon,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
}: FolderEditDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedColor, setSelectedColor] = useState(
|
||||
currentColor || AVAILABLE_COLORS[0].value,
|
||||
);
|
||||
const [selectedIcon, setSelectedIcon] = useState(
|
||||
currentIcon || AVAILABLE_ICONS[0].value,
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedColor(currentColor || AVAILABLE_COLORS[0].value);
|
||||
setSelectedIcon(currentIcon || AVAILABLE_ICONS[0].value);
|
||||
}
|
||||
}, [open, currentColor, currentIcon]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await onSave(selectedColor, selectedIcon);
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to save folder metadata:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px] bg-dark-bg border-2 border-dark-border">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Folder className="w-5 h-5" />
|
||||
{t("hosts.editFolderAppearance")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
{t("hosts.editFolderAppearanceDesc")}:{" "}
|
||||
<span className="font-mono text-foreground">{folderName}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold text-foreground">
|
||||
{t("hosts.folderColor")}
|
||||
</Label>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{AVAILABLE_COLORS.map((color) => (
|
||||
<button
|
||||
key={color.value}
|
||||
type="button"
|
||||
className={`h-12 rounded-md border-2 transition-all hover:scale-105 ${
|
||||
selectedColor === color.value
|
||||
? "border-white shadow-lg scale-105"
|
||||
: "border-dark-border"
|
||||
}`}
|
||||
style={{ backgroundColor: color.value }}
|
||||
onClick={() => setSelectedColor(color.value)}
|
||||
title={color.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold text-foreground">
|
||||
{t("hosts.folderIcon")}
|
||||
</Label>
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
{AVAILABLE_ICONS.map(({ value, label, Icon }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className={`h-14 rounded-md border-2 transition-all hover:scale-105 flex items-center justify-center ${
|
||||
selectedIcon === value
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-dark-border bg-dark-bg-darker"
|
||||
}`}
|
||||
onClick={() => setSelectedIcon(value)}
|
||||
title={label}
|
||||
>
|
||||
<Icon className="w-6 h-6" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold text-foreground">
|
||||
{t("hosts.preview")}
|
||||
</Label>
|
||||
<div className="flex items-center gap-3 p-4 rounded-md bg-dark-bg-darker border border-dark-border">
|
||||
{(() => {
|
||||
const IconComponent =
|
||||
AVAILABLE_ICONS.find((i) => i.value === selectedIcon)?.Icon ||
|
||||
Folder;
|
||||
return (
|
||||
<IconComponent
|
||||
className="w-5 h-5"
|
||||
style={{ color: selectedColor }}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
<span className="font-medium">{folderName}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={loading}>
|
||||
{loading ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -3,10 +3,10 @@ import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||
import { Status, StatusIndicator } from "@/components/ui/shadcn-io/status";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Tunnel } from "@/ui/desktop/apps/tunnel/Tunnel.tsx";
|
||||
import {
|
||||
getServerStatusById,
|
||||
getServerMetricsById,
|
||||
executeSnippet,
|
||||
type ServerMetrics,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
@@ -25,7 +25,14 @@ import {
|
||||
UptimeWidget,
|
||||
ProcessesWidget,
|
||||
SystemWidget,
|
||||
LoginStatsWidget,
|
||||
} from "./widgets";
|
||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||
|
||||
interface QuickAction {
|
||||
name: string;
|
||||
snippetId: number;
|
||||
}
|
||||
|
||||
interface HostConfig {
|
||||
id: number;
|
||||
@@ -35,6 +42,7 @@ interface HostConfig {
|
||||
folder?: string;
|
||||
enableFileManager?: boolean;
|
||||
tunnelConnections?: unknown[];
|
||||
quickActions?: QuickAction[];
|
||||
statsConfig?: string | StatsConfig;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -55,7 +63,7 @@ interface ServerProps {
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export function Server({
|
||||
export function ServerStats({
|
||||
hostConfig,
|
||||
title,
|
||||
isVisible = true,
|
||||
@@ -79,6 +87,9 @@ export function Server({
|
||||
const [isLoadingMetrics, setIsLoadingMetrics] = React.useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = React.useState(false);
|
||||
const [showStatsUI, setShowStatsUI] = React.useState(true);
|
||||
const [executingActions, setExecutingActions] = React.useState<Set<number>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const statsConfig = React.useMemo((): StatsConfig => {
|
||||
if (!currentHostConfig?.statsConfig) {
|
||||
@@ -101,8 +112,14 @@ export function Server({
|
||||
const metricsEnabled = statsConfig.metricsEnabled !== false;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hostConfig?.id !== currentHostConfig?.id) {
|
||||
setServerStatus("offline");
|
||||
setMetrics(null);
|
||||
setMetricsHistory([]);
|
||||
setShowStatsUI(true);
|
||||
}
|
||||
setCurrentHostConfig(hostConfig);
|
||||
}, [hostConfig]);
|
||||
}, [hostConfig?.id]);
|
||||
|
||||
const renderWidget = (widgetType: WidgetType) => {
|
||||
switch (widgetType) {
|
||||
@@ -137,6 +154,11 @@ export function Server({
|
||||
<SystemWidget metrics={metrics} metricsHistory={metricsHistory} />
|
||||
);
|
||||
|
||||
case "login_stats":
|
||||
return (
|
||||
<LoginStatsWidget metrics={metrics} metricsHistory={metricsHistory} />
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -436,70 +458,148 @@ export function Server({
|
||||
<Separator className="p-0.25 w-full" />
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{metricsEnabled && showStatsUI && (
|
||||
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4 max-h-[50vh] overflow-y-auto">
|
||||
{isLoadingMetrics && !metrics ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
|
||||
<span className="text-gray-300">
|
||||
{t("serverStats.loadingMetrics")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : !metrics && serverStatus === "offline" ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-red-500/20 flex items-center justify-center">
|
||||
<div className="w-6 h-6 border-2 border-red-400 rounded-full"></div>
|
||||
{(metricsEnabled && showStatsUI) ||
|
||||
(currentHostConfig?.quickActions &&
|
||||
currentHostConfig.quickActions.length > 0) ? (
|
||||
<div className="rounded-lg border-dark-border m-3 p-1 overflow-y-auto relative flex-1 flex flex-col">
|
||||
{currentHostConfig?.quickActions &&
|
||||
currentHostConfig.quickActions.length > 0 && (
|
||||
<div className={metricsEnabled && showStatsUI ? "mb-4" : ""}>
|
||||
<h3 className="text-sm font-semibold text-gray-400 mb-2">
|
||||
{t("serverStats.quickActions")}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{currentHostConfig.quickActions.map((action, index) => {
|
||||
const isExecuting = executingActions.has(
|
||||
action.snippetId,
|
||||
);
|
||||
return (
|
||||
<Button
|
||||
key={index}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="font-semibold"
|
||||
disabled={isExecuting}
|
||||
onClick={async () => {
|
||||
if (!currentHostConfig) return;
|
||||
|
||||
setExecutingActions((prev) =>
|
||||
new Set(prev).add(action.snippetId),
|
||||
);
|
||||
toast.loading(
|
||||
t("serverStats.executingQuickAction", {
|
||||
name: action.name,
|
||||
}),
|
||||
{ id: `quick-action-${action.snippetId}` },
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await executeSnippet(
|
||||
action.snippetId,
|
||||
currentHostConfig.id,
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
toast.success(
|
||||
t("serverStats.quickActionSuccess", {
|
||||
name: action.name,
|
||||
}),
|
||||
{
|
||||
id: `quick-action-${action.snippetId}`,
|
||||
description: result.output
|
||||
? result.output.substring(0, 200)
|
||||
: undefined,
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
t("serverStats.quickActionFailed", {
|
||||
name: action.name,
|
||||
}),
|
||||
{
|
||||
id: `quick-action-${action.snippetId}`,
|
||||
description:
|
||||
result.error ||
|
||||
result.output ||
|
||||
undefined,
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(
|
||||
t("serverStats.quickActionError", {
|
||||
name: action.name,
|
||||
}),
|
||||
{
|
||||
id: `quick-action-${action.snippetId}`,
|
||||
description:
|
||||
error?.message || "Unknown error",
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
setExecutingActions((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(action.snippetId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}}
|
||||
title={t("serverStats.executeQuickAction", {
|
||||
name: action.name,
|
||||
})}
|
||||
>
|
||||
{isExecuting ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 border-2 border-gray-300 border-t-transparent rounded-full animate-spin"></div>
|
||||
{action.name}
|
||||
</div>
|
||||
) : (
|
||||
action.name
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-gray-300 mb-1">
|
||||
{t("serverStats.serverOffline")}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t("serverStats.cannotFetchMetrics")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{enabledWidgets.map((widgetType) => (
|
||||
<div key={widgetType} className="h-[280px]">
|
||||
{renderWidget(widgetType)}
|
||||
)}
|
||||
{metricsEnabled &&
|
||||
showStatsUI &&
|
||||
(!metrics && serverStatus === "offline" ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-red-500/20 flex items-center justify-center">
|
||||
<div className="w-6 h-6 border-2 border-red-400 rounded-full"></div>
|
||||
</div>
|
||||
<p className="text-gray-300 mb-1">
|
||||
{t("serverStats.serverOffline")}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t("serverStats.cannotFetchMetrics")}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{enabledWidgets.map((widgetType) => (
|
||||
<div key={widgetType} className="h-[280px]">
|
||||
{renderWidget(widgetType)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{metricsEnabled && showStatsUI && (
|
||||
<SimpleLoader
|
||||
visible={isLoadingMetrics && !metrics}
|
||||
message={t("serverStats.loadingMetrics")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentHostConfig?.tunnelConnections &&
|
||||
currentHostConfig.tunnelConnections.length > 0 && (
|
||||
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker h-[360px] overflow-hidden flex flex-col min-h-0">
|
||||
<Tunnel
|
||||
filterHostKey={
|
||||
currentHostConfig?.name &&
|
||||
currentHostConfig.name.trim() !== ""
|
||||
? currentHostConfig.name
|
||||
: `${currentHostConfig?.username}@${currentHostConfig?.ip}`
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p className="px-4 pt-2 pb-2 text-sm text-gray-500">
|
||||
{t("serverStats.feedbackMessage")}{" "}
|
||||
<a
|
||||
href="https://github.com/Termix-SSH/Termix/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:underline"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -30,7 +30,7 @@ export function CpuWidget({ metrics, metricsHistory }: CpuWidgetProps) {
|
||||
}, [metricsHistory]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
<div className="h-full w-full p-4 rounded-lg bg-dark-bg-darker border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||
<Cpu className="h-5 w-5 text-blue-400" />
|
||||
<h3 className="font-semibold text-lg text-white">
|
||||
@@ -27,7 +27,7 @@ export function DiskWidget({ metrics }: DiskWidgetProps) {
|
||||
}, [metrics]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
<div className="h-full w-full p-4 rounded-lg bg-dark-bg-darker border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||
<HardDrive className="h-5 w-5 text-orange-400" />
|
||||
<h3 className="font-semibold text-lg text-white">
|
||||
142
src/ui/desktop/apps/server-stats/widgets/LoginStatsWidget.tsx
Normal file
142
src/ui/desktop/apps/server-stats/widgets/LoginStatsWidget.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import React from "react";
|
||||
import { UserCheck, UserX, MapPin, Activity } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface LoginRecord {
|
||||
user: string;
|
||||
ip: string;
|
||||
time: string;
|
||||
status: "success" | "failed";
|
||||
}
|
||||
|
||||
interface LoginStatsMetrics {
|
||||
recentLogins: LoginRecord[];
|
||||
failedLogins: LoginRecord[];
|
||||
totalLogins: number;
|
||||
uniqueIPs: number;
|
||||
}
|
||||
|
||||
interface ServerMetrics {
|
||||
login_stats?: LoginStatsMetrics;
|
||||
}
|
||||
|
||||
interface LoginStatsWidgetProps {
|
||||
metrics: ServerMetrics | null;
|
||||
metricsHistory: ServerMetrics[];
|
||||
}
|
||||
|
||||
export function LoginStatsWidget({ metrics }: LoginStatsWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const loginStats = metrics?.login_stats;
|
||||
const recentLogins = loginStats?.recentLogins || [];
|
||||
const failedLogins = loginStats?.failedLogins || [];
|
||||
const totalLogins = loginStats?.totalLogins || 0;
|
||||
const uniqueIPs = loginStats?.uniqueIPs || 0;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full p-4 rounded-lg bg-dark-bg-darker border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||
<UserCheck className="h-5 w-5 text-green-400" />
|
||||
<h3 className="font-semibold text-lg text-white">
|
||||
{t("serverStats.loginStats")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col flex-1 min-h-0 gap-3">
|
||||
<div className="grid grid-cols-2 gap-2 flex-shrink-0">
|
||||
<div className="bg-dark-bg-darker p-2 rounded border border-dark-border/30">
|
||||
<div className="flex items-center gap-1 text-xs text-gray-400 mb-1">
|
||||
<Activity className="h-3 w-3" />
|
||||
<span>{t("serverStats.totalLogins")}</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold text-green-400">
|
||||
{totalLogins}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-dark-bg-darker p-2 rounded border border-dark-border/30">
|
||||
<div className="flex items-center gap-1 text-xs text-gray-400 mb-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
<span>{t("serverStats.uniqueIPs")}</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold text-blue-400">{uniqueIPs}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto space-y-2">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<UserCheck className="h-4 w-4 text-green-400" />
|
||||
<span className="text-sm font-semibold text-gray-300">
|
||||
{t("serverStats.recentSuccessfulLogins")}
|
||||
</span>
|
||||
</div>
|
||||
{recentLogins.length === 0 ? (
|
||||
<div className="text-xs text-gray-500 italic p-2">
|
||||
{t("serverStats.noRecentLoginData")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{recentLogins.slice(0, 5).map((login, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="text-xs bg-dark-bg-darker p-2 rounded border border-dark-border/30 flex justify-between items-center"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-green-400 font-mono truncate">
|
||||
{login.user}
|
||||
</span>
|
||||
<span className="text-gray-500">
|
||||
{t("serverStats.from")}
|
||||
</span>
|
||||
<span className="text-blue-400 font-mono truncate">
|
||||
{login.ip}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-gray-500 text-[10px] flex-shrink-0 ml-2">
|
||||
{new Date(login.time).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{failedLogins.length > 0 && (
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<UserX className="h-4 w-4 text-red-400" />
|
||||
<span className="text-sm font-semibold text-gray-300">
|
||||
{t("serverStats.recentFailedAttempts")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{failedLogins.slice(0, 3).map((login, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="text-xs bg-red-900/20 p-2 rounded border border-red-500/30 flex justify-between items-center"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-red-400 font-mono truncate">
|
||||
{login.user}
|
||||
</span>
|
||||
<span className="text-gray-500">
|
||||
{t("serverStats.from")}
|
||||
</span>
|
||||
<span className="text-blue-400 font-mono truncate">
|
||||
{login.ip}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-gray-500 text-[10px] flex-shrink-0 ml-2">
|
||||
{new Date(login.time).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export function MemoryWidget({ metrics, metricsHistory }: MemoryWidgetProps) {
|
||||
}, [metricsHistory]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
<div className="h-full w-full p-4 rounded-lg bg-dark-bg-darker border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||
<MemoryStick className="h-5 w-5 text-green-400" />
|
||||
<h3 className="font-semibold text-lg text-white">
|
||||
@@ -24,7 +24,7 @@ export function NetworkWidget({ metrics }: NetworkWidgetProps) {
|
||||
const interfaces = network?.interfaces || [];
|
||||
|
||||
return (
|
||||
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
<div className="h-full w-full p-4 rounded-lg bg-dark-bg-darker border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||
<Network className="h-5 w-5 text-indigo-400" />
|
||||
<h3 className="font-semibold text-lg text-white">
|
||||
@@ -28,7 +28,7 @@ export function ProcessesWidget({ metrics }: ProcessesWidgetProps) {
|
||||
const topProcesses = processes?.top || [];
|
||||
|
||||
return (
|
||||
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
<div className="h-full w-full p-4 rounded-lg bg-dark-bg-darker border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||
<List className="h-5 w-5 text-yellow-400" />
|
||||
<h3 className="font-semibold text-lg text-white">
|
||||
@@ -21,7 +21,7 @@ export function SystemWidget({ metrics }: SystemWidgetProps) {
|
||||
const system = metricsWithSystem?.system;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
<div className="h-full w-full p-4 rounded-lg bg-dark-bg-darker border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||
<Server className="h-5 w-5 text-purple-400" />
|
||||
<h3 className="font-semibold text-lg text-white">
|
||||
@@ -20,7 +20,7 @@ export function UptimeWidget({ metrics }: UptimeWidgetProps) {
|
||||
const uptime = metricsWithUptime?.uptime;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
<div className="h-full w-full p-4 rounded-lg bg-dark-bg-darker border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||
<Clock className="h-5 w-5 text-cyan-400" />
|
||||
<h3 className="font-semibold text-lg text-white">
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user