Compare commits
60 Commits
i18n_trans
...
dev-1.10.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7872770a1 | ||
|
|
004ddcb2bb | ||
|
|
8eeef84b8a | ||
|
|
dc88ae5e8b | ||
|
|
cb478477e9 | ||
|
|
b7bd1e50b3 | ||
|
|
230ab2f737 | ||
|
|
042bf255ef | ||
|
|
f7e99b5af5 | ||
|
|
8fa093ae60 | ||
|
|
dd62b77c79 | ||
|
|
264682c5ad | ||
|
|
7210381f17 | ||
|
|
c0f4f1d74b | ||
|
|
f957959a86 | ||
|
|
a54dbe5b46 | ||
|
|
ac1cf82bba | ||
|
|
de556e3911 | ||
|
|
9be6b945c8 | ||
|
|
1aebbee21e | ||
|
|
80c09aef7d | ||
|
|
115a1fd7f0 | ||
|
|
a1c260ad22 | ||
|
|
18aa4f4877 | ||
|
|
8fc038e59b | ||
|
|
aea87be4d3 | ||
|
|
7caa32b364 | ||
|
|
868ac39b71 | ||
|
|
8ae8520c44 | ||
|
|
8ce4c6f364 | ||
|
|
a9a1a4b3d5 | ||
|
|
2e3f7e10c7 | ||
|
|
d821373b15 | ||
|
|
816172d67b | ||
|
|
ceff07c685 | ||
|
|
1eb28dec8b | ||
|
|
2b6361cbb6 | ||
|
|
e6870f962a | ||
|
|
99b0181c45 | ||
|
|
58945288e0 | ||
|
|
afb66a1098 | ||
|
|
5f080be4ee | ||
|
|
4648549e74 | ||
|
|
f5d948aa45 | ||
|
|
81d506afba | ||
|
|
4150faa558 | ||
|
|
7ecfb4d685 | ||
|
|
614f2f84ec | ||
|
|
af63fe1b7b | ||
|
|
4896b71b01 | ||
|
|
69f3f88ae5 | ||
|
|
d632f2b91f | ||
|
|
5366cb24ef | ||
|
|
177e783f92 | ||
|
|
1a2179c345 | ||
|
|
bdf9ea282e | ||
|
|
6feb8405ce | ||
|
|
2ee1318ded | ||
|
|
0216a2d2fe | ||
|
|
8106999d1e |
2
.github/workflows/docker.yml
vendored
2
.github/workflows/docker.yml
vendored
@@ -17,7 +17,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
|
|||||||
27
.github/workflows/electron.yml
vendored
27
.github/workflows/electron.yml
vendored
@@ -356,7 +356,7 @@ jobs:
|
|||||||
|
|
||||||
build-macos:
|
build-macos:
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
if: github.event.inputs.build_type == 'macos' || github.event.inputs.build_type == 'all'
|
if: (github.event.inputs.build_type == 'macos' || github.event.inputs.build_type == 'all') && github.event.inputs.artifact_destination != 'submit'
|
||||||
needs: []
|
needs: []
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -584,7 +584,7 @@ jobs:
|
|||||||
|
|
||||||
submit-to-chocolatey:
|
submit-to-chocolatey:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
if: github.event.inputs.artifact_destination == 'submit'
|
if: github.event.inputs.artifact_destination == 'submit' && (github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'windows' || github.event.inputs.build_type == '')
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
@@ -689,7 +689,7 @@ jobs:
|
|||||||
|
|
||||||
submit-to-flatpak:
|
submit-to-flatpak:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.event.inputs.artifact_destination == 'submit'
|
if: github.event.inputs.artifact_destination == 'submit' && (github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'linux' || github.event.inputs.build_type == '')
|
||||||
needs: []
|
needs: []
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -776,7 +776,7 @@ jobs:
|
|||||||
|
|
||||||
submit-to-homebrew:
|
submit-to-homebrew:
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
if: github.event.inputs.artifact_destination == 'submit'
|
if: github.event.inputs.artifact_destination == 'submit' && (github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'macos')
|
||||||
needs: []
|
needs: []
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -801,11 +801,20 @@ jobs:
|
|||||||
URL="https://github.com/Termix-SSH/Termix/releases/download/release-$VERSION-tag/$DMG_NAME"
|
URL="https://github.com/Termix-SSH/Termix/releases/download/release-$VERSION-tag/$DMG_NAME"
|
||||||
|
|
||||||
mkdir -p release_asset
|
mkdir -p release_asset
|
||||||
PATH="release_asset/$DMG_NAME"
|
DOWNLOAD_PATH="release_asset/$DMG_NAME"
|
||||||
echo "Downloading DMG from $URL"
|
echo "Downloading DMG from $URL"
|
||||||
curl -L -o "$PATH" "$URL"
|
|
||||||
|
|
||||||
CHECKSUM=$(shasum -a 256 "$PATH" | awk '{print $1}')
|
if command -v curl &> /dev/null; then
|
||||||
|
curl -L -o "$DOWNLOAD_PATH" "$URL"
|
||||||
|
elif command -v wget &> /dev/null; then
|
||||||
|
wget -O "$DOWNLOAD_PATH" "$URL"
|
||||||
|
else
|
||||||
|
echo "Neither curl nor wget is available, installing curl"
|
||||||
|
brew install curl
|
||||||
|
curl -L -o "$DOWNLOAD_PATH" "$URL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
CHECKSUM=$(shasum -a 256 "$DOWNLOAD_PATH" | awk '{print $1}')
|
||||||
|
|
||||||
echo "dmg_name=$DMG_NAME" >> $GITHUB_OUTPUT
|
echo "dmg_name=$DMG_NAME" >> $GITHUB_OUTPUT
|
||||||
echo "checksum=$CHECKSUM" >> $GITHUB_OUTPUT
|
echo "checksum=$CHECKSUM" >> $GITHUB_OUTPUT
|
||||||
@@ -872,7 +881,7 @@ jobs:
|
|||||||
|
|
||||||
submit-to-testflight:
|
submit-to-testflight:
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
if: github.event.inputs.artifact_destination == 'submit'
|
if: github.event.inputs.artifact_destination == 'submit' && (github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'macos')
|
||||||
needs: []
|
needs: []
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -977,7 +986,7 @@ jobs:
|
|||||||
- name: Deploy to App Store Connect (TestFlight)
|
- name: Deploy to App Store Connect (TestFlight)
|
||||||
if: steps.check_asc_creds.outputs.has_credentials == 'true'
|
if: steps.check_asc_creds.outputs.has_credentials == 'true'
|
||||||
run: |
|
run: |
|
||||||
PKG_FILE=$(find artifact-mas -name "*.pkg" -type f | head -n 1)
|
PKG_FILE=$(find release -name "termix_macos_universal_mas.pkg" -type f | head -n 1)
|
||||||
if [ -z "$PKG_FILE" ]; then
|
if [ -z "$PKG_FILE" ]; then
|
||||||
echo "PKG file not found, exiting."
|
echo "PKG file not found, exiting."
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
32
.github/workflows/openapi.yml
vendored
Normal file
32
.github/workflows/openapi.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
name: Generate OpenAPI Specification
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
generate-openapi:
|
||||||
|
name: Generate OpenAPI JSON
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Generate OpenAPI specification
|
||||||
|
run: npm run generate:openapi
|
||||||
|
|
||||||
|
- name: Upload OpenAPI artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: openapi-spec
|
||||||
|
path: openapi.json
|
||||||
|
retention-days: 90
|
||||||
437
.github/workflows/translate.yml
vendored
437
.github/workflows/translate.yml
vendored
@@ -1,437 +0,0 @@
|
|||||||
name: Auto Translate
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
translate-zh:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t zh --maxLinesPerRequest 1
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: translations-zh
|
|
||||||
path: src/locales/zh.json
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
translate-ru:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t ru --maxLinesPerRequest 1
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: translations-ru
|
|
||||||
path: src/locales/ru.json
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
translate-pt:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t pt --maxLinesPerRequest 1
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: translations-pt
|
|
||||||
path: src/locales/pt.json
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
translate-fr:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t fr --maxLinesPerRequest 1
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: translations-fr
|
|
||||||
path: src/locales/fr.json
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
translate-es:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t es --maxLinesPerRequest 1
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: translations-es
|
|
||||||
path: src/locales/es.json
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
translate-de:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t de --maxLinesPerRequest 1
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: translations-de
|
|
||||||
path: src/locales/de.json
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
translate-hi:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t hi --maxLinesPerRequest 1
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: translations-hi
|
|
||||||
path: src/locales/hi.json
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
translate-bn:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t bn --maxLinesPerRequest 1
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: translations-bn
|
|
||||||
path: src/locales/bn.json
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
translate-ja:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t ja --maxLinesPerRequest 1
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: translations-ja
|
|
||||||
path: src/locales/ja.json
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
translate-vi:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t vi --maxLinesPerRequest 1
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: translations-vi
|
|
||||||
path: src/locales/vi.json
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
translate-tr:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t tr --maxLinesPerRequest 1
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: translations-tr
|
|
||||||
path: src/locales/tr.json
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
translate-ko:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t ko --maxLinesPerRequest 1
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: translations-ko
|
|
||||||
path: src/locales/ko.json
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
translate-it:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t it --maxLinesPerRequest 1
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: translations-it
|
|
||||||
path: src/locales/it.json
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
translate-he:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t he --maxLinesPerRequest 1
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: translations-he
|
|
||||||
path: src/locales/he.json
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
translate-ar:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t ar --maxLinesPerRequest 1
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: translations-ar
|
|
||||||
path: src/locales/ar.json
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
translate-pl:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t pl --maxLinesPerRequest 1
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: translations-pl
|
|
||||||
path: src/locales/pl.json
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
translate-nl:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t nl --maxLinesPerRequest 1
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: translations-nl
|
|
||||||
path: src/locales/nl.json
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
translate-sv:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t sv --maxLinesPerRequest 1
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: translations-sv
|
|
||||||
path: src/locales/sv.json
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
translate-id:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t id --maxLinesPerRequest 1
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: translations-id
|
|
||||||
path: src/locales/id.json
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
translate-th:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t th --maxLinesPerRequest 1
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: translations-th
|
|
||||||
path: src/locales/th.json
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
translate-uk:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t uk --maxLinesPerRequest 1
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: translations-uk
|
|
||||||
path: src/locales/uk.json
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
translate-cs:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t cs --maxLinesPerRequest 1
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: translations-cs
|
|
||||||
path: src/locales/cs.json
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
translate-ro:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t ro --maxLinesPerRequest 1
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: translations-ro
|
|
||||||
path: src/locales/ro.json
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
translate-el:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t el --maxLinesPerRequest 1
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: translations-el
|
|
||||||
path: src/locales/el.json
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
translate-nb:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t nb --maxLinesPerRequest 1
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: translations-nb
|
|
||||||
path: src/locales/nb.json
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
create-pr:
|
|
||||||
needs:
|
|
||||||
[
|
|
||||||
translate-zh,
|
|
||||||
translate-ru,
|
|
||||||
translate-pt,
|
|
||||||
translate-fr,
|
|
||||||
translate-es,
|
|
||||||
translate-de,
|
|
||||||
translate-hi,
|
|
||||||
translate-bn,
|
|
||||||
translate-ja,
|
|
||||||
translate-vi,
|
|
||||||
translate-tr,
|
|
||||||
translate-ko,
|
|
||||||
translate-it,
|
|
||||||
translate-he,
|
|
||||||
translate-ar,
|
|
||||||
translate-pl,
|
|
||||||
translate-nl,
|
|
||||||
translate-sv,
|
|
||||||
translate-id,
|
|
||||||
translate-th,
|
|
||||||
translate-uk,
|
|
||||||
translate-cs,
|
|
||||||
translate-ro,
|
|
||||||
translate-el,
|
|
||||||
translate-nb,
|
|
||||||
]
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GHCR_TOKEN }}
|
|
||||||
|
|
||||||
- name: Download all artifacts
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
path: translations-temp
|
|
||||||
|
|
||||||
- name: Move translations to src/locales
|
|
||||||
run: |
|
|
||||||
cp translations-temp/translations-zh/zh.json src/locales/ 2>/dev/null || true
|
|
||||||
cp translations-temp/translations-ru/ru.json src/locales/ 2>/dev/null || true
|
|
||||||
cp translations-temp/translations-pt/pt.json src/locales/ 2>/dev/null || true
|
|
||||||
cp translations-temp/translations-fr/fr.json src/locales/ 2>/dev/null || true
|
|
||||||
cp translations-temp/translations-es/es.json src/locales/ 2>/dev/null || true
|
|
||||||
cp translations-temp/translations-de/de.json src/locales/ 2>/dev/null || true
|
|
||||||
cp translations-temp/translations-hi/hi.json src/locales/ 2>/dev/null || true
|
|
||||||
cp translations-temp/translations-bn/bn.json src/locales/ 2>/dev/null || true
|
|
||||||
cp translations-temp/translations-ja/ja.json src/locales/ 2>/dev/null || true
|
|
||||||
cp translations-temp/translations-vi/vi.json src/locales/ 2>/dev/null || true
|
|
||||||
cp translations-temp/translations-tr/tr.json src/locales/ 2>/dev/null || true
|
|
||||||
cp translations-temp/translations-ko/ko.json src/locales/ 2>/dev/null || true
|
|
||||||
cp translations-temp/translations-it/it.json src/locales/ 2>/dev/null || true
|
|
||||||
cp translations-temp/translations-he/he.json src/locales/ 2>/dev/null || true
|
|
||||||
cp translations-temp/translations-ar/ar.json src/locales/ 2>/dev/null || true
|
|
||||||
cp translations-temp/translations-pl/pl.json src/locales/ 2>/dev/null || true
|
|
||||||
cp translations-temp/translations-nl/nl.json src/locales/ 2>/dev/null || true
|
|
||||||
cp translations-temp/translations-sv/sv.json src/locales/ 2>/dev/null || true
|
|
||||||
cp translations-temp/translations-id/id.json src/locales/ 2>/dev/null || true
|
|
||||||
cp translations-temp/translations-th/th.json src/locales/ 2>/dev/null || true
|
|
||||||
cp translations-temp/translations-uk/uk.json src/locales/ 2>/dev/null || true
|
|
||||||
cp translations-temp/translations-cs/cs.json src/locales/ 2>/dev/null || true
|
|
||||||
cp translations-temp/translations-ro/ro.json src/locales/ 2>/dev/null || true
|
|
||||||
cp translations-temp/translations-el/el.json src/locales/ 2>/dev/null || true
|
|
||||||
cp translations-temp/translations-nb/nb.json src/locales/ 2>/dev/null || true
|
|
||||||
rm -rf translations-temp
|
|
||||||
|
|
||||||
- name: Create Pull Request
|
|
||||||
uses: peter-evans/create-pull-request@v6
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GHCR_TOKEN }}
|
|
||||||
commit-message: "chore: auto-translate to multiple languages"
|
|
||||||
branch: translations-update
|
|
||||||
delete-branch: true
|
|
||||||
title: "chore: Update translations for all languages"
|
|
||||||
24
README.md
24
README.md
@@ -16,17 +16,6 @@
|
|||||||
<small style="color: #666;">Achieved on September 1st, 2025</small>
|
<small style="color: #666;">Achieved on September 1st, 2025</small>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
#### Top Technologies
|
|
||||||
|
|
||||||
[](#)
|
|
||||||
[](#)
|
|
||||||
[](#)
|
|
||||||
[](#)
|
|
||||||
[](#)
|
|
||||||
[](#)
|
|
||||||
[](#)
|
|
||||||
[](#)
|
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/Termix-SSH/Termix">
|
<a href="https://github.com/Termix-SSH/Termix">
|
||||||
@@ -85,6 +74,7 @@ Supported Devices:
|
|||||||
- Chocolatey Package Manager
|
- Chocolatey Package Manager
|
||||||
- Linux (x64/ia32)
|
- Linux (x64/ia32)
|
||||||
- Portable
|
- Portable
|
||||||
|
- AUR
|
||||||
- AppImage
|
- AppImage
|
||||||
- Deb
|
- Deb
|
||||||
- Flatpak
|
- Flatpak
|
||||||
@@ -120,6 +110,18 @@ volumes:
|
|||||||
driver: local
|
driver: local
|
||||||
```
|
```
|
||||||
|
|
||||||
|
# Sponsors
|
||||||
|
|
||||||
|
<p align="left">
|
||||||
|
<a href="https://www.digitalocean.com/">
|
||||||
|
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" height="50" alt="DigitalOcean">
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="https://crowdin.com/">
|
||||||
|
<img src="https://support.crowdin.com/assets/logos/core-logo/svg/crowdin-core-logo-cDark.svg" height="50" alt="Crowdin">
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
# Support
|
# Support
|
||||||
|
|
||||||
If you need help or want to request a feature with Termix, visit the [Issues](https://github.com/Termix-SSH/Support/issues) page, log in, and press `New Issue`.
|
If you need help or want to request a feature with Termix, visit the [Issues](https://github.com/Termix-SSH/Support/issues) page, log in, and press `New Issue`.
|
||||||
|
|||||||
3
crowdin.yml
Normal file
3
crowdin.yml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
files:
|
||||||
|
- source: /src/locales/en.json
|
||||||
|
translation: /src/locales/translated/%two_letters_code%.json
|
||||||
@@ -19,7 +19,7 @@ COPY . .
|
|||||||
RUN find public/fonts -name "*.ttf" ! -name "*Regular.ttf" ! -name "*Bold.ttf" ! -name "*Italic.ttf" -delete
|
RUN find public/fonts -name "*.ttf" ! -name "*Regular.ttf" ! -name "*Bold.ttf" ! -name "*Italic.ttf" -delete
|
||||||
|
|
||||||
RUN npm cache clean --force && \
|
RUN npm cache clean --force && \
|
||||||
npm run build
|
NODE_OPTIONS="--max-old-space-size=2048" npm run build
|
||||||
|
|
||||||
# Stage 3: Build backend
|
# Stage 3: Build backend
|
||||||
FROM deps AS backend-builder
|
FROM deps AS backend-builder
|
||||||
@@ -74,6 +74,9 @@ VOLUME ["/app/data"]
|
|||||||
|
|
||||||
EXPOSE ${PORT} 30001 30002 30003 30004 30005 30006
|
EXPOSE ${PORT} 30001 30002 30003 30004 30005 30006
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||||
|
CMD node -e "require('http').get('http://localhost:30001/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"
|
||||||
|
|
||||||
COPY docker/entrypoint.sh /entrypoint.sh
|
COPY docker/entrypoint.sh /entrypoint.sh
|
||||||
RUN chmod +x /entrypoint.sh
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
worker_processes 1;
|
||||||
|
master_process off;
|
||||||
pid /app/nginx/nginx.pid;
|
pid /app/nginx/nginx.pid;
|
||||||
error_log /app/nginx/logs/error.log warn;
|
error_log /app/nginx/logs/error.log warn;
|
||||||
|
|
||||||
@@ -199,6 +201,18 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location /ssh/quick-connect {
|
||||||
|
proxy_pass http://127.0.0.1:30001;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
location /ssh/ {
|
location /ssh/ {
|
||||||
proxy_pass http://127.0.0.1:30001;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -286,6 +300,15 @@ http {
|
|||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location ~ ^/network-topology(/.*)?$ {
|
||||||
|
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 /health {
|
location /health {
|
||||||
proxy_pass http://127.0.0.1:30001;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -335,6 +358,15 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location ~ ^/dashboard/preferences(/.*)?$ {
|
||||||
|
proxy_pass http://127.0.0.1:30006;
|
||||||
|
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 ^~ /docker/console/ {
|
location ^~ /docker/console/ {
|
||||||
proxy_pass http://127.0.0.1:30008/;
|
proxy_pass http://127.0.0.1:30008/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
worker_processes 1;
|
||||||
|
master_process off;
|
||||||
pid /app/nginx/nginx.pid;
|
pid /app/nginx/nginx.pid;
|
||||||
error_log /app/nginx/logs/error.log warn;
|
error_log /app/nginx/logs/error.log warn;
|
||||||
|
|
||||||
@@ -188,6 +190,18 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location /ssh/quick-connect {
|
||||||
|
proxy_pass http://127.0.0.1:30001;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
location /ssh/ {
|
location /ssh/ {
|
||||||
proxy_pass http://127.0.0.1:30001;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -275,6 +289,15 @@ http {
|
|||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location ~ ^/network-topology(/.*)?$ {
|
||||||
|
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 /health {
|
location /health {
|
||||||
proxy_pass http://127.0.0.1:30001;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -324,6 +347,15 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location ~ ^/dashboard/preferences(/.*)?$ {
|
||||||
|
proxy_pass http://127.0.0.1:30006;
|
||||||
|
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 ^~ /docker/console/ {
|
location ^~ /docker/console/ {
|
||||||
proxy_pass http://127.0.0.1:30008/;
|
proxy_pass http://127.0.0.1:30008/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@@ -4,6 +4,13 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<!-- PWA Meta Tags -->
|
||||||
|
<meta name="theme-color" content="#09090b" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Termix" />
|
||||||
|
<link rel="apple-touch-icon" href="/icons/512x512.png" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
<title>Termix</title>
|
<title>Termix</title>
|
||||||
<style>
|
<style>
|
||||||
.hide-scrollbar {
|
.hide-scrollbar {
|
||||||
|
|||||||
2305
openapi.json
2305
openapi.json
File diff suppressed because it is too large
Load Diff
262
package-lock.json
generated
262
package-lock.json
generated
@@ -34,6 +34,7 @@
|
|||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/cookie-parser": "^1.4.9",
|
"@types/cookie-parser": "^1.4.9",
|
||||||
|
"@types/cytoscape": "^3.21.9",
|
||||||
"@types/jszip": "^3.4.0",
|
"@types/jszip": "^3.4.0",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
@@ -56,6 +57,7 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"cytoscape": "^3.33.1",
|
||||||
"dotenv": "^17.2.0",
|
"dotenv": "^17.2.0",
|
||||||
"drizzle-orm": "^0.44.3",
|
"drizzle-orm": "^0.44.3",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
@@ -72,6 +74,7 @@
|
|||||||
"node-fetch": "^3.3.2",
|
"node-fetch": "^3.3.2",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
|
"react-cytoscapejs": "^2.0.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-h5-audio-player": "^3.10.1",
|
"react-h5-audio-player": "^3.10.1",
|
||||||
"react-hook-form": "^7.60.0",
|
"react-hook-form": "^7.60.0",
|
||||||
@@ -122,11 +125,60 @@
|
|||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^16.2.3",
|
"lint-staged": "^16.2.3",
|
||||||
"prettier": "3.6.2",
|
"prettier": "3.6.2",
|
||||||
|
"swagger-jsdoc": "^6.2.8",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
"typescript-eslint": "^8.40.0",
|
"typescript-eslint": "^8.40.0",
|
||||||
"vite": "^7.1.5"
|
"vite": "^7.1.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@apidevtools/json-schema-ref-parser": {
|
||||||
|
"version": "9.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz",
|
||||||
|
"integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jsdevtools/ono": "^7.1.3",
|
||||||
|
"@types/json-schema": "^7.0.6",
|
||||||
|
"call-me-maybe": "^1.0.1",
|
||||||
|
"js-yaml": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@apidevtools/openapi-schemas": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@apidevtools/swagger-methods": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@apidevtools/swagger-parser": {
|
||||||
|
"version": "10.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz",
|
||||||
|
"integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@apidevtools/json-schema-ref-parser": "^9.0.6",
|
||||||
|
"@apidevtools/openapi-schemas": "^2.0.4",
|
||||||
|
"@apidevtools/swagger-methods": "^3.0.2",
|
||||||
|
"@jsdevtools/ono": "^7.1.3",
|
||||||
|
"call-me-maybe": "^1.0.1",
|
||||||
|
"z-schema": "^5.0.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"openapi-types": ">=7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.27.1",
|
"version": "7.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||||
@@ -2619,6 +2671,13 @@
|
|||||||
"url": "https://opencollective.com/js-sdsl"
|
"url": "https://opencollective.com/js-sdsl"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@jsdevtools/ono": {
|
||||||
|
"version": "7.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
|
||||||
|
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@lezer/common": {
|
"node_modules/@lezer/common": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.3.0.tgz",
|
||||||
@@ -5228,6 +5287,12 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/cytoscape": {
|
||||||
|
"version": "3.21.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/cytoscape/-/cytoscape-3.21.9.tgz",
|
||||||
|
"integrity": "sha512-JyrG4tllI6jvuISPjHK9j2Xv/LTbnLekLke5otGStjFluIyA9JjgnvgZrSBsp8cEDpiTjwgZUZwpPv8TSBcoLw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/d3-array": {
|
"node_modules/@types/d3-array": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
@@ -6958,6 +7023,13 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/call-me-maybe": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/callsites": {
|
"node_modules/callsites": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||||
@@ -7776,6 +7848,15 @@
|
|||||||
"integrity": "sha512-cjrsQufETwxjvwZbYbKBCJNvmQ2++G9AvT45zDi7NXL9k2PdVcs2h0jQz96J6G4TMKRCcEsoJ+QTgQD00Igtjw==",
|
"integrity": "sha512-cjrsQufETwxjvwZbYbKBCJNvmQ2++G9AvT45zDi7NXL9k2PdVcs2h0jQz96J6G4TMKRCcEsoJ+QTgQD00Igtjw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cytoscape": {
|
||||||
|
"version": "3.33.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz",
|
||||||
|
"integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/d3-array": {
|
"node_modules/d3-array": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
@@ -8304,6 +8385,19 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/doctrine": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"esutils": "^2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dompurify": {
|
"node_modules/dompurify": {
|
||||||
"version": "3.1.7",
|
"version": "3.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.7.tgz",
|
||||||
@@ -12195,6 +12289,14 @@
|
|||||||
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
|
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.get": {
|
||||||
|
"version": "4.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
|
||||||
|
"integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==",
|
||||||
|
"deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.includes": {
|
"node_modules/lodash.includes": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||||
@@ -12207,6 +12309,14 @@
|
|||||||
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.isequal": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
|
||||||
|
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.isinteger": {
|
"node_modules/lodash.isinteger": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||||
@@ -14068,6 +14178,14 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/openapi-types": {
|
||||||
|
"version": "12.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
|
||||||
|
"integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/optionator": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@@ -15109,6 +15227,19 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-cytoscapejs": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-cytoscapejs/-/react-cytoscapejs-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-t3SSl1DQy7+JQjN+8QHi1anEJlM3i3aAeydHTsJwmjo/isyKK7Rs7oCvU6kZsB9NwZidzZQR21Vm2PcBLG/Tjg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prop-types": "^15.8.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"cytoscape": "^3.2.19",
|
||||||
|
"react": ">=15.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "19.2.0",
|
"version": "19.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||||
@@ -16717,6 +16848,95 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/swagger-jsdoc": {
|
||||||
|
"version": "6.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz",
|
||||||
|
"integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"commander": "6.2.0",
|
||||||
|
"doctrine": "3.0.0",
|
||||||
|
"glob": "7.1.6",
|
||||||
|
"lodash.mergewith": "^4.6.2",
|
||||||
|
"swagger-parser": "^10.0.3",
|
||||||
|
"yaml": "2.0.0-1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"swagger-jsdoc": "bin/swagger-jsdoc.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/swagger-jsdoc/node_modules/commander": {
|
||||||
|
"version": "6.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz",
|
||||||
|
"integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/swagger-jsdoc/node_modules/glob": {
|
||||||
|
"version": "7.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
|
||||||
|
"integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
|
||||||
|
"deprecated": "Glob versions prior to v9 are no longer supported",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"fs.realpath": "^1.0.0",
|
||||||
|
"inflight": "^1.0.4",
|
||||||
|
"inherits": "2",
|
||||||
|
"minimatch": "^3.0.4",
|
||||||
|
"once": "^1.3.0",
|
||||||
|
"path-is-absolute": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/swagger-jsdoc/node_modules/minimatch": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"brace-expansion": "^1.1.7"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/swagger-jsdoc/node_modules/yaml": {
|
||||||
|
"version": "2.0.0-1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz",
|
||||||
|
"integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/swagger-parser": {
|
||||||
|
"version": "10.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz",
|
||||||
|
"integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@apidevtools/swagger-parser": "10.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tailwind-merge": {
|
"node_modules/tailwind-merge": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz",
|
||||||
@@ -17566,6 +17786,16 @@
|
|||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/validator": {
|
||||||
|
"version": "13.15.26",
|
||||||
|
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz",
|
||||||
|
"integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vary": {
|
"node_modules/vary": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
@@ -18120,6 +18350,38 @@
|
|||||||
"integrity": "sha512-YHDIOAqgRpfl1Ois9HcB8UFtWOxK8KJrV5TXpImj4BKYP1rWT04f/fMM9tQ9SYZlBKukT7NR+9wcI3UpB5BMDQ==",
|
"integrity": "sha512-YHDIOAqgRpfl1Ois9HcB8UFtWOxK8KJrV5TXpImj4BKYP1rWT04f/fMM9tQ9SYZlBKukT7NR+9wcI3UpB5BMDQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/z-schema": {
|
||||||
|
"version": "5.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz",
|
||||||
|
"integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lodash.get": "^4.4.2",
|
||||||
|
"lodash.isequal": "^4.5.0",
|
||||||
|
"validator": "^13.7.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"z-schema": "bin/z-schema"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"commander": "^9.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/z-schema/node_modules/commander": {
|
||||||
|
"version": "9.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
|
||||||
|
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.20.0 || >=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "termix",
|
"name": "termix",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.10.0",
|
"version": "1.10.1",
|
||||||
"description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities",
|
"description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities",
|
||||||
"author": "Karmaa",
|
"author": "Karmaa",
|
||||||
"main": "electron/main.cjs",
|
"main": "electron/main.cjs",
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
"build": "vite build && tsc -p tsconfig.node.json",
|
"build": "vite build && tsc -p tsconfig.node.json",
|
||||||
"build:backend": "tsc -p tsconfig.node.json",
|
"build:backend": "tsc -p tsconfig.node.json",
|
||||||
"dev:backend": "tsc -p tsconfig.node.json && node ./dist/backend/backend/starter.js",
|
"dev:backend": "tsc -p tsconfig.node.json && node ./dist/backend/backend/starter.js",
|
||||||
|
"generate:openapi": "tsc -p tsconfig.node.json && node ./dist/backend/backend/swagger.js",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"electron:dev": "concurrently \"npm run dev\" \"powershell -c \\\"Start-Sleep -Seconds 5\\\" && electron .\"",
|
"electron:dev": "concurrently \"npm run dev\" \"powershell -c \\\"Start-Sleep -Seconds 5\\\" && electron .\"",
|
||||||
"build:win-portable": "npm run build && electron-builder --win --dir",
|
"build:win-portable": "npm run build && electron-builder --win --dir",
|
||||||
@@ -53,6 +54,7 @@
|
|||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/cookie-parser": "^1.4.9",
|
"@types/cookie-parser": "^1.4.9",
|
||||||
|
"@types/cytoscape": "^3.21.9",
|
||||||
"@types/jszip": "^3.4.0",
|
"@types/jszip": "^3.4.0",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
@@ -75,6 +77,7 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"cytoscape": "^3.33.1",
|
||||||
"dotenv": "^17.2.0",
|
"dotenv": "^17.2.0",
|
||||||
"drizzle-orm": "^0.44.3",
|
"drizzle-orm": "^0.44.3",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
@@ -91,6 +94,7 @@
|
|||||||
"node-fetch": "^3.3.2",
|
"node-fetch": "^3.3.2",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
|
"react-cytoscapejs": "^2.0.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-h5-audio-player": "^3.10.1",
|
"react-h5-audio-player": "^3.10.1",
|
||||||
"react-hook-form": "^7.60.0",
|
"react-hook-form": "^7.60.0",
|
||||||
@@ -141,6 +145,7 @@
|
|||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^16.2.3",
|
"lint-staged": "^16.2.3",
|
||||||
"prettier": "3.6.2",
|
"prettier": "3.6.2",
|
||||||
|
"swagger-jsdoc": "^6.2.8",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
"typescript-eslint": "^8.40.0",
|
"typescript-eslint": "^8.40.0",
|
||||||
"vite": "^7.1.5"
|
"vite": "^7.1.5"
|
||||||
|
|||||||
40
public/manifest.json
Normal file
40
public/manifest.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "Termix",
|
||||||
|
"short_name": "Termix",
|
||||||
|
"description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities",
|
||||||
|
"theme_color": "#09090b",
|
||||||
|
"background_color": "#09090b",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "any",
|
||||||
|
"scope": "/",
|
||||||
|
"start_url": "/",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/48x48.png",
|
||||||
|
"sizes": "48x48",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/64x64.png",
|
||||||
|
"sizes": "64x64",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/128x128.png",
|
||||||
|
"sizes": "128x128",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/256x256.png",
|
||||||
|
"sizes": "256x256",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"categories": ["utilities", "developer", "productivity"]
|
||||||
|
}
|
||||||
120
public/sw.js
Normal file
120
public/sw.js
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* Termix Service Worker
|
||||||
|
* Handles caching for offline PWA support
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CACHE_NAME = "termix-v1";
|
||||||
|
const STATIC_ASSETS = [
|
||||||
|
"/",
|
||||||
|
"/index.html",
|
||||||
|
"/manifest.json",
|
||||||
|
"/favicon.ico",
|
||||||
|
"/icons/48x48.png",
|
||||||
|
"/icons/128x128.png",
|
||||||
|
"/icons/256x256.png",
|
||||||
|
"/icons/512x512.png",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Install event - cache static assets
|
||||||
|
self.addEventListener("install", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches
|
||||||
|
.open(CACHE_NAME)
|
||||||
|
.then((cache) => {
|
||||||
|
console.log("[SW] Caching static assets");
|
||||||
|
return cache.addAll(STATIC_ASSETS);
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
// Activate immediately without waiting
|
||||||
|
return self.skipWaiting();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Activate event - clean up old caches
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches
|
||||||
|
.keys()
|
||||||
|
.then((cacheNames) => {
|
||||||
|
return Promise.all(
|
||||||
|
cacheNames
|
||||||
|
.filter((name) => name !== CACHE_NAME)
|
||||||
|
.map((name) => {
|
||||||
|
console.log("[SW] Deleting old cache:", name);
|
||||||
|
return caches.delete(name);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
// Take control of all pages immediately
|
||||||
|
return self.clients.claim();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch event - serve from cache, fall back to network
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
const { request } = event;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// Skip non-GET requests
|
||||||
|
if (request.method !== "GET") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip API requests - these must be online
|
||||||
|
if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/ws")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip cross-origin requests
|
||||||
|
if (url.origin !== self.location.origin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For navigation requests (HTML), use network-first
|
||||||
|
if (request.mode === "navigate") {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(request)
|
||||||
|
.then((response) => {
|
||||||
|
// Clone and cache the response
|
||||||
|
const responseClone = response.clone();
|
||||||
|
caches.open(CACHE_NAME).then((cache) => {
|
||||||
|
cache.put(request, responseClone);
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Offline: return cached index.html
|
||||||
|
return caches.match("/index.html");
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For all other assets, use cache-first
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(request).then((cachedResponse) => {
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not in cache, fetch from network
|
||||||
|
return fetch(request).then((response) => {
|
||||||
|
// Don't cache non-successful responses
|
||||||
|
if (!response || response.status !== 200 || response.type !== "basic") {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone and cache the response
|
||||||
|
const responseClone = response.clone();
|
||||||
|
caches.open(CACHE_NAME).then((cache) => {
|
||||||
|
cache.put(request, responseClone);
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import cookieParser from "cookie-parser";
|
import cookieParser from "cookie-parser";
|
||||||
import { getDb } from "./database/db/index.js";
|
import { getDb, DatabaseSaveTrigger } from "./database/db/index.js";
|
||||||
import { recentActivity, sshData, hostAccess } from "./database/db/schema.js";
|
import {
|
||||||
import { eq, and, desc, or } from "drizzle-orm";
|
recentActivity,
|
||||||
|
sshData,
|
||||||
|
hostAccess,
|
||||||
|
dashboardPreferences,
|
||||||
|
} from "./database/db/schema.js";
|
||||||
|
import { eq, and, desc, or, sql } from "drizzle-orm";
|
||||||
import { dashboardLogger } from "./utils/logger.js";
|
import { dashboardLogger } from "./utils/logger.js";
|
||||||
import { SimpleDBOps } from "./utils/simple-db-ops.js";
|
import { SimpleDBOps } from "./utils/simple-db-ops.js";
|
||||||
import { AuthManager } from "./utils/auth-manager.js";
|
import { AuthManager } from "./utils/auth-manager.js";
|
||||||
@@ -58,6 +63,31 @@ app.use(express.json({ limit: "1mb" }));
|
|||||||
|
|
||||||
app.use(authManager.createAuthMiddleware());
|
app.use(authManager.createAuthMiddleware());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /uptime:
|
||||||
|
* get:
|
||||||
|
* summary: Get server uptime
|
||||||
|
* description: Returns the uptime of the server in various formats.
|
||||||
|
* tags:
|
||||||
|
* - Dashboard
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Server uptime information.
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* uptimeMs:
|
||||||
|
* type: number
|
||||||
|
* uptimeSeconds:
|
||||||
|
* type: number
|
||||||
|
* formatted:
|
||||||
|
* type: string
|
||||||
|
* 500:
|
||||||
|
* description: Failed to get uptime.
|
||||||
|
*/
|
||||||
app.get("/uptime", async (req, res) => {
|
app.get("/uptime", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const uptimeMs = Date.now() - serverStartTime;
|
const uptimeMs = Date.now() - serverStartTime;
|
||||||
@@ -77,6 +107,28 @@ app.get("/uptime", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /activity/recent:
|
||||||
|
* get:
|
||||||
|
* summary: Get recent activity
|
||||||
|
* description: Fetches the most recent activities for the authenticated user.
|
||||||
|
* tags:
|
||||||
|
* - Dashboard
|
||||||
|
* parameters:
|
||||||
|
* - in: query
|
||||||
|
* name: limit
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* description: The maximum number of activities to return.
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: A list of recent activities.
|
||||||
|
* 401:
|
||||||
|
* description: Session expired.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to get recent activity.
|
||||||
|
*/
|
||||||
app.get("/activity/recent", async (req, res) => {
|
app.get("/activity/recent", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -108,6 +160,40 @@ app.get("/activity/recent", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /activity/log:
|
||||||
|
* post:
|
||||||
|
* summary: Log a new activity
|
||||||
|
* description: Logs a new user activity, such as accessing a terminal or file manager. This endpoint is rate-limited.
|
||||||
|
* tags:
|
||||||
|
* - Dashboard
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* type:
|
||||||
|
* type: string
|
||||||
|
* enum: [terminal, file_manager, server_stats, tunnel, docker]
|
||||||
|
* hostId:
|
||||||
|
* type: integer
|
||||||
|
* hostName:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Activity logged successfully or rate-limited.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid request body.
|
||||||
|
* 401:
|
||||||
|
* description: Session expired.
|
||||||
|
* 404:
|
||||||
|
* description: Host not found or access denied.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to log activity.
|
||||||
|
*/
|
||||||
app.post("/activity/log", async (req, res) => {
|
app.post("/activity/log", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -224,6 +310,22 @@ app.post("/activity/log", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /activity/reset:
|
||||||
|
* delete:
|
||||||
|
* summary: Reset recent activity
|
||||||
|
* description: Clears all recent activity for the authenticated user.
|
||||||
|
* tags:
|
||||||
|
* - Dashboard
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Recent activity cleared.
|
||||||
|
* 401:
|
||||||
|
* description: Session expired.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to reset activity.
|
||||||
|
*/
|
||||||
app.delete("/activity/reset", async (req, res) => {
|
app.delete("/activity/reset", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -253,6 +355,166 @@ app.delete("/activity/reset", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /dashboard/preferences:
|
||||||
|
* get:
|
||||||
|
* summary: Get dashboard layout preferences
|
||||||
|
* description: Returns the user's customized dashboard layout settings. If no preferences exist, returns default layout.
|
||||||
|
* tags:
|
||||||
|
* - Dashboard
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Dashboard preferences retrieved
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* cards:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* id:
|
||||||
|
* type: string
|
||||||
|
* enabled:
|
||||||
|
* type: boolean
|
||||||
|
* order:
|
||||||
|
* type: integer
|
||||||
|
* gridColumns:
|
||||||
|
* type: integer
|
||||||
|
* 401:
|
||||||
|
* description: Session expired
|
||||||
|
* 500:
|
||||||
|
* description: Failed to get preferences
|
||||||
|
*/
|
||||||
|
app.get("/dashboard/preferences", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
|
||||||
|
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||||
|
return res.status(401).json({
|
||||||
|
error: "Session expired - please log in again",
|
||||||
|
code: "SESSION_EXPIRED",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferences = await getDb()
|
||||||
|
.select()
|
||||||
|
.from(dashboardPreferences)
|
||||||
|
.where(eq(dashboardPreferences.userId, userId));
|
||||||
|
|
||||||
|
if (preferences.length === 0) {
|
||||||
|
const defaultLayout = {
|
||||||
|
cards: [
|
||||||
|
{ id: "server_overview", enabled: true, order: 1 },
|
||||||
|
{ id: "recent_activity", enabled: true, order: 2 },
|
||||||
|
{ id: "network_graph", enabled: false, order: 3 },
|
||||||
|
{ id: "quick_actions", enabled: true, order: 4 },
|
||||||
|
{ id: "server_stats", enabled: true, order: 5 },
|
||||||
|
],
|
||||||
|
gridColumns: 2,
|
||||||
|
};
|
||||||
|
return res.json(defaultLayout);
|
||||||
|
}
|
||||||
|
|
||||||
|
const layout = JSON.parse(preferences[0].layout as string);
|
||||||
|
res.json(layout);
|
||||||
|
} catch (err) {
|
||||||
|
dashboardLogger.error("Failed to get dashboard preferences", err);
|
||||||
|
res.status(500).json({ error: "Failed to get dashboard preferences" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /dashboard/preferences:
|
||||||
|
* post:
|
||||||
|
* summary: Save dashboard layout preferences
|
||||||
|
* description: Saves or updates the user's customized dashboard layout settings.
|
||||||
|
* tags:
|
||||||
|
* - Dashboard
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* cards:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* id:
|
||||||
|
* type: string
|
||||||
|
* enabled:
|
||||||
|
* type: boolean
|
||||||
|
* order:
|
||||||
|
* type: integer
|
||||||
|
* gridColumns:
|
||||||
|
* type: integer
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Preferences saved successfully
|
||||||
|
* 400:
|
||||||
|
* description: Invalid request body
|
||||||
|
* 401:
|
||||||
|
* description: Session expired
|
||||||
|
* 500:
|
||||||
|
* description: Failed to save preferences
|
||||||
|
*/
|
||||||
|
app.post("/dashboard/preferences", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
|
||||||
|
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||||
|
return res.status(401).json({
|
||||||
|
error: "Session expired - please log in again",
|
||||||
|
code: "SESSION_EXPIRED",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { cards, gridColumns } = req.body;
|
||||||
|
|
||||||
|
if (!cards || !Array.isArray(cards) || typeof gridColumns !== "number") {
|
||||||
|
return res.status(400).json({
|
||||||
|
error:
|
||||||
|
"Invalid request body. Expected { cards: Array, gridColumns: number }",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const layout = JSON.stringify({ cards, gridColumns });
|
||||||
|
|
||||||
|
const existing = await getDb()
|
||||||
|
.select()
|
||||||
|
.from(dashboardPreferences)
|
||||||
|
.where(eq(dashboardPreferences.userId, userId));
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
await getDb()
|
||||||
|
.update(dashboardPreferences)
|
||||||
|
.set({ layout, updatedAt: sql`CURRENT_TIMESTAMP` })
|
||||||
|
.where(eq(dashboardPreferences.userId, userId));
|
||||||
|
} else {
|
||||||
|
await getDb().insert(dashboardPreferences).values({ userId, layout });
|
||||||
|
}
|
||||||
|
|
||||||
|
await DatabaseSaveTrigger.triggerSave("dashboard_preferences_updated");
|
||||||
|
|
||||||
|
dashboardLogger.success("Dashboard preferences saved", {
|
||||||
|
operation: "save_dashboard_preferences",
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, message: "Dashboard preferences saved" });
|
||||||
|
} catch (err) {
|
||||||
|
dashboardLogger.error("Failed to save dashboard preferences", err);
|
||||||
|
res.status(500).json({ error: "Failed to save dashboard preferences" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const PORT = 30006;
|
const PORT = 30006;
|
||||||
app.listen(PORT, async () => {
|
app.listen(PORT, async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import alertRoutes from "./routes/alerts.js";
|
|||||||
import credentialsRoutes from "./routes/credentials.js";
|
import credentialsRoutes from "./routes/credentials.js";
|
||||||
import snippetsRoutes from "./routes/snippets.js";
|
import snippetsRoutes from "./routes/snippets.js";
|
||||||
import terminalRoutes from "./routes/terminal.js";
|
import terminalRoutes from "./routes/terminal.js";
|
||||||
|
import networkTopologyRoutes from "./routes/network-topology.js";
|
||||||
import rbacRoutes from "./routes/rbac.js";
|
import rbacRoutes from "./routes/rbac.js";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import fetch from "node-fetch";
|
import fetch from "node-fetch";
|
||||||
@@ -205,10 +206,46 @@ app.use(bodyParser.urlencoded({ limit: "1gb", extended: true }));
|
|||||||
app.use(bodyParser.raw({ limit: "5gb", type: "application/octet-stream" }));
|
app.use(bodyParser.raw({ limit: "5gb", type: "application/octet-stream" }));
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /health:
|
||||||
|
* get:
|
||||||
|
* summary: Health check
|
||||||
|
* description: Returns the health status of the server.
|
||||||
|
* tags:
|
||||||
|
* - General
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Server is healthy.
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* status:
|
||||||
|
* type: string
|
||||||
|
* example: ok
|
||||||
|
*/
|
||||||
app.get("/health", (req, res) => {
|
app.get("/health", (req, res) => {
|
||||||
res.json({ status: "ok" });
|
res.json({ status: "ok" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /version:
|
||||||
|
* get:
|
||||||
|
* summary: Get version information
|
||||||
|
* description: Returns the local and remote version of the application.
|
||||||
|
* tags:
|
||||||
|
* - General
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Version information.
|
||||||
|
* 404:
|
||||||
|
* description: Local version not set.
|
||||||
|
* 500:
|
||||||
|
* description: Fetch error.
|
||||||
|
*/
|
||||||
app.get("/version", authenticateJWT, async (req, res) => {
|
app.get("/version", authenticateJWT, async (req, res) => {
|
||||||
let localVersion = process.env.VERSION;
|
let localVersion = process.env.VERSION;
|
||||||
|
|
||||||
@@ -307,6 +344,31 @@ app.get("/version", authenticateJWT, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /releases/rss:
|
||||||
|
* get:
|
||||||
|
* summary: Get releases in RSS format
|
||||||
|
* description: Returns the latest releases from the GitHub repository in an RSS-like JSON format.
|
||||||
|
* tags:
|
||||||
|
* - General
|
||||||
|
* parameters:
|
||||||
|
* - in: query
|
||||||
|
* name: page
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* description: The page number of the releases to fetch.
|
||||||
|
* - in: query
|
||||||
|
* name: per_page
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* description: The number of releases to fetch per page.
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Releases in RSS format.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to generate RSS format.
|
||||||
|
*/
|
||||||
app.get("/releases/rss", authenticateJWT, async (req, res) => {
|
app.get("/releases/rss", authenticateJWT, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const page = parseInt(req.query.page as string) || 1;
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
@@ -363,6 +425,20 @@ app.get("/releases/rss", authenticateJWT, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /encryption/status:
|
||||||
|
* get:
|
||||||
|
* summary: Get encryption status
|
||||||
|
* description: Returns the security status of the application.
|
||||||
|
* tags:
|
||||||
|
* - Encryption
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Security status.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to get security status.
|
||||||
|
*/
|
||||||
app.get("/encryption/status", requireAdmin, async (req, res) => {
|
app.get("/encryption/status", requireAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const securityStatus = {
|
const securityStatus = {
|
||||||
@@ -384,6 +460,20 @@ app.get("/encryption/status", requireAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /encryption/initialize:
|
||||||
|
* post:
|
||||||
|
* summary: Initialize security system
|
||||||
|
* description: Initializes the security system for the application.
|
||||||
|
* tags:
|
||||||
|
* - Encryption
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Security system initialized successfully.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to initialize security system.
|
||||||
|
*/
|
||||||
app.post("/encryption/initialize", requireAdmin, async (req, res) => {
|
app.post("/encryption/initialize", requireAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
@@ -407,6 +497,20 @@ app.post("/encryption/initialize", requireAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /encryption/regenerate:
|
||||||
|
* post:
|
||||||
|
* summary: Regenerate JWT secret
|
||||||
|
* description: Regenerates the system JWT secret. This will invalidate all existing JWT tokens.
|
||||||
|
* tags:
|
||||||
|
* - Encryption
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: System JWT secret regenerated.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to regenerate JWT secret.
|
||||||
|
*/
|
||||||
app.post("/encryption/regenerate", requireAdmin, async (req, res) => {
|
app.post("/encryption/regenerate", requireAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
apiLogger.warn("System JWT secret regenerated via API", {
|
apiLogger.warn("System JWT secret regenerated via API", {
|
||||||
@@ -428,6 +532,20 @@ app.post("/encryption/regenerate", requireAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /encryption/regenerate-jwt:
|
||||||
|
* post:
|
||||||
|
* summary: Regenerate JWT secret
|
||||||
|
* description: Regenerates the JWT secret. This will invalidate all existing JWT tokens.
|
||||||
|
* tags:
|
||||||
|
* - Encryption
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: New JWT secret generated.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to regenerate JWT secret.
|
||||||
|
*/
|
||||||
app.post("/encryption/regenerate-jwt", requireAdmin, async (req, res) => {
|
app.post("/encryption/regenerate-jwt", requireAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
apiLogger.warn("JWT secret regenerated via API", {
|
apiLogger.warn("JWT secret regenerated via API", {
|
||||||
@@ -448,6 +566,33 @@ app.post("/encryption/regenerate-jwt", requireAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /database/export:
|
||||||
|
* post:
|
||||||
|
* summary: Export user data
|
||||||
|
* description: Exports the user's data as a SQLite database file.
|
||||||
|
* tags:
|
||||||
|
* - Database
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* password:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: User data exported successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Password required for export.
|
||||||
|
* 401:
|
||||||
|
* description: Invalid password.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to export user data.
|
||||||
|
*/
|
||||||
app.post("/database/export", authenticateJWT, async (req, res) => {
|
app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -898,6 +1043,36 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /database/import:
|
||||||
|
* post:
|
||||||
|
* summary: Import user data
|
||||||
|
* description: Imports user data from a SQLite database file.
|
||||||
|
* tags:
|
||||||
|
* - Database
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* multipart/form-data:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* file:
|
||||||
|
* type: string
|
||||||
|
* format: binary
|
||||||
|
* password:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Incremental import completed successfully.
|
||||||
|
* 400:
|
||||||
|
* description: No file uploaded or password required for import.
|
||||||
|
* 401:
|
||||||
|
* description: Invalid password.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to import SQLite data.
|
||||||
|
*/
|
||||||
app.post(
|
app.post(
|
||||||
"/database/import",
|
"/database/import",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -1362,6 +1537,31 @@ app.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /database/export/preview:
|
||||||
|
* post:
|
||||||
|
* summary: Preview user data export
|
||||||
|
* description: Generates a preview of the user data export, including statistics about the data.
|
||||||
|
* tags:
|
||||||
|
* - Database
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* scope:
|
||||||
|
* type: string
|
||||||
|
* includeCredentials:
|
||||||
|
* type: boolean
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Export preview generated successfully.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to generate export preview.
|
||||||
|
*/
|
||||||
app.post("/database/export/preview", authenticateJWT, async (req, res) => {
|
app.post("/database/export/preview", authenticateJWT, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -1397,6 +1597,33 @@ app.post("/database/export/preview", authenticateJWT, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /database/restore:
|
||||||
|
* post:
|
||||||
|
* summary: Restore database from backup
|
||||||
|
* description: Restores the database from an encrypted backup file.
|
||||||
|
* tags:
|
||||||
|
* - Database
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* backupPath:
|
||||||
|
* type: string
|
||||||
|
* targetPath:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Database restored successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Backup path is required or invalid encrypted backup file.
|
||||||
|
* 500:
|
||||||
|
* description: Database restore failed.
|
||||||
|
*/
|
||||||
app.post("/database/restore", requireAdmin, async (req, res) => {
|
app.post("/database/restore", requireAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { backupPath, targetPath } = req.body;
|
const { backupPath, targetPath } = req.body;
|
||||||
@@ -1437,6 +1664,7 @@ app.use("/alerts", alertRoutes);
|
|||||||
app.use("/credentials", credentialsRoutes);
|
app.use("/credentials", credentialsRoutes);
|
||||||
app.use("/snippets", snippetsRoutes);
|
app.use("/snippets", snippetsRoutes);
|
||||||
app.use("/terminal", terminalRoutes);
|
app.use("/terminal", terminalRoutes);
|
||||||
|
app.use("/network-topology", networkTopologyRoutes);
|
||||||
app.use("/rbac", rbacRoutes);
|
app.use("/rbac", rbacRoutes);
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
@@ -1477,6 +1705,20 @@ async function initializeSecurity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /database/migration/status:
|
||||||
|
* get:
|
||||||
|
* summary: Get database migration status
|
||||||
|
* description: Returns the status of the database migration.
|
||||||
|
* tags:
|
||||||
|
* - Database
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Migration status.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to get migration status.
|
||||||
|
*/
|
||||||
app.get(
|
app.get(
|
||||||
"/database/migration/status",
|
"/database/migration/status",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -1530,6 +1772,20 @@ app.get(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /database/migration/history:
|
||||||
|
* get:
|
||||||
|
* summary: Get database migration history
|
||||||
|
* description: Returns the history of database migrations.
|
||||||
|
* tags:
|
||||||
|
* - Database
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Migration history.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to get migration history.
|
||||||
|
*/
|
||||||
app.get(
|
app.get(
|
||||||
"/database/migration/history",
|
"/database/migration/history",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
|||||||
@@ -585,6 +585,32 @@ const migrateSchema = () => {
|
|||||||
addColumnIfNotExists("ssh_data", "socks5_password", "TEXT");
|
addColumnIfNotExists("ssh_data", "socks5_password", "TEXT");
|
||||||
addColumnIfNotExists("ssh_data", "socks5_proxy_chain", "TEXT");
|
addColumnIfNotExists("ssh_data", "socks5_proxy_chain", "TEXT");
|
||||||
|
|
||||||
|
addColumnIfNotExists(
|
||||||
|
"ssh_data",
|
||||||
|
"show_terminal_in_sidebar",
|
||||||
|
"INTEGER NOT NULL DEFAULT 1",
|
||||||
|
);
|
||||||
|
addColumnIfNotExists(
|
||||||
|
"ssh_data",
|
||||||
|
"show_file_manager_in_sidebar",
|
||||||
|
"INTEGER NOT NULL DEFAULT 0",
|
||||||
|
);
|
||||||
|
addColumnIfNotExists(
|
||||||
|
"ssh_data",
|
||||||
|
"show_tunnel_in_sidebar",
|
||||||
|
"INTEGER NOT NULL DEFAULT 0",
|
||||||
|
);
|
||||||
|
addColumnIfNotExists(
|
||||||
|
"ssh_data",
|
||||||
|
"show_docker_in_sidebar",
|
||||||
|
"INTEGER NOT NULL DEFAULT 0",
|
||||||
|
);
|
||||||
|
addColumnIfNotExists(
|
||||||
|
"ssh_data",
|
||||||
|
"show_server_stats_in_sidebar",
|
||||||
|
"INTEGER NOT NULL DEFAULT 0",
|
||||||
|
);
|
||||||
|
|
||||||
addColumnIfNotExists("ssh_credentials", "private_key", "TEXT");
|
addColumnIfNotExists("ssh_credentials", "private_key", "TEXT");
|
||||||
addColumnIfNotExists("ssh_credentials", "public_key", "TEXT");
|
addColumnIfNotExists("ssh_credentials", "public_key", "TEXT");
|
||||||
addColumnIfNotExists("ssh_credentials", "detected_key_type", "TEXT");
|
addColumnIfNotExists("ssh_credentials", "detected_key_type", "TEXT");
|
||||||
@@ -653,6 +679,54 @@ const migrateSchema = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
sqlite
|
||||||
|
.prepare("SELECT id FROM network_topology LIMIT 1")
|
||||||
|
.get();
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS network_topology (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
topology 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 network_topology table", {
|
||||||
|
operation: "schema_migration",
|
||||||
|
error: createError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
sqlite
|
||||||
|
.prepare("SELECT id FROM dashboard_preferences LIMIT 1")
|
||||||
|
.get();
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS dashboard_preferences (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL UNIQUE,
|
||||||
|
layout TEXT NOT NULL,
|
||||||
|
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 dashboard_preferences table", {
|
||||||
|
operation: "schema_migration",
|
||||||
|
error: createError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
sqlite.prepare("SELECT id FROM host_access LIMIT 1").get();
|
sqlite.prepare("SELECT id FROM host_access LIMIT 1").get();
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -90,6 +90,21 @@ export const sshData = sqliteTable("ssh_data", {
|
|||||||
enableDocker: integer("enable_docker", { mode: "boolean" })
|
enableDocker: integer("enable_docker", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
|
showTerminalInSidebar: integer("show_terminal_in_sidebar", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(true),
|
||||||
|
showFileManagerInSidebar: integer("show_file_manager_in_sidebar", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
showTunnelInSidebar: integer("show_tunnel_in_sidebar", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
showDockerInSidebar: integer("show_docker_in_sidebar", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
showServerStatsInSidebar: integer("show_server_stats_in_sidebar", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
defaultPath: text("default_path"),
|
defaultPath: text("default_path"),
|
||||||
statsConfig: text("stats_config"),
|
statsConfig: text("stats_config"),
|
||||||
terminalConfig: text("terminal_config"),
|
terminalConfig: text("terminal_config"),
|
||||||
@@ -295,6 +310,35 @@ export const commandHistory = sqliteTable("command_history", {
|
|||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const networkTopology = sqliteTable("network_topology", {
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
topology: text("topology"),
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
updatedAt: text("updated_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const dashboardPreferences = sqliteTable("dashboard_preferences", {
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.unique()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
layout: text("layout").notNull(),
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
updatedAt: text("updated_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
});
|
||||||
|
|
||||||
export const hostAccess = sqliteTable("host_access", {
|
export const hostAccess = sqliteTable("host_access", {
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
hostId: integer("host_id")
|
hostId: integer("host_id")
|
||||||
|
|||||||
@@ -99,8 +99,20 @@ const router = express.Router();
|
|||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
const authenticateJWT = authManager.createAuthMiddleware();
|
const authenticateJWT = authManager.createAuthMiddleware();
|
||||||
|
|
||||||
// Route: Get alerts for the authenticated user (excluding dismissed ones)
|
/**
|
||||||
// GET /alerts
|
* @openapi
|
||||||
|
* /alerts:
|
||||||
|
* get:
|
||||||
|
* summary: Get active alerts
|
||||||
|
* description: Fetches active alerts for the authenticated user, excluding those that have been dismissed.
|
||||||
|
* tags:
|
||||||
|
* - Alerts
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: A list of active alerts.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to fetch alerts.
|
||||||
|
*/
|
||||||
router.get("/", authenticateJWT, async (req, res) => {
|
router.get("/", authenticateJWT, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -131,8 +143,33 @@ router.get("/", authenticateJWT, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Route: Dismiss an alert for the authenticated user
|
/**
|
||||||
// POST /alerts/dismiss
|
* @openapi
|
||||||
|
* /alerts/dismiss:
|
||||||
|
* post:
|
||||||
|
* summary: Dismiss an alert
|
||||||
|
* description: Marks an alert as dismissed for the authenticated user.
|
||||||
|
* tags:
|
||||||
|
* - Alerts
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* alertId:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Alert dismissed successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Alert ID is required.
|
||||||
|
* 409:
|
||||||
|
* description: Alert already dismissed.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to dismiss alert.
|
||||||
|
*/
|
||||||
router.post("/dismiss", authenticateJWT, async (req, res) => {
|
router.post("/dismiss", authenticateJWT, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { alertId } = req.body;
|
const { alertId } = req.body;
|
||||||
@@ -170,8 +207,20 @@ router.post("/dismiss", authenticateJWT, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Route: Get dismissed alerts for a user
|
/**
|
||||||
// GET /alerts/dismissed/:userId
|
* @openapi
|
||||||
|
* /alerts/dismissed:
|
||||||
|
* get:
|
||||||
|
* summary: Get dismissed alerts
|
||||||
|
* description: Fetches a list of alerts that have been dismissed by the authenticated user.
|
||||||
|
* tags:
|
||||||
|
* - Alerts
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: A list of dismissed alerts.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to fetch dismissed alerts.
|
||||||
|
*/
|
||||||
router.get("/dismissed", authenticateJWT, async (req, res) => {
|
router.get("/dismissed", authenticateJWT, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -194,8 +243,33 @@ router.get("/dismissed", authenticateJWT, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Route: Undismiss an alert for the authenticated user (remove from dismissed list)
|
/**
|
||||||
// DELETE /alerts/dismiss
|
* @openapi
|
||||||
|
* /alerts/dismiss:
|
||||||
|
* delete:
|
||||||
|
* summary: Undismiss an alert
|
||||||
|
* description: Removes an alert from the dismissed list for the authenticated user.
|
||||||
|
* tags:
|
||||||
|
* - Alerts
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* alertId:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Alert undismissed successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Alert ID is required.
|
||||||
|
* 404:
|
||||||
|
* description: Dismissed alert not found.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to undismiss alert.
|
||||||
|
*/
|
||||||
router.delete("/dismiss", authenticateJWT, async (req, res) => {
|
router.delete("/dismiss", authenticateJWT, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { alertId } = req.body;
|
const { alertId } = req.body;
|
||||||
|
|||||||
@@ -84,8 +84,52 @@ const authManager = AuthManager.getInstance();
|
|||||||
const authenticateJWT = authManager.createAuthMiddleware();
|
const authenticateJWT = authManager.createAuthMiddleware();
|
||||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||||
|
|
||||||
// Create a new credential
|
/**
|
||||||
// POST /credentials
|
* @openapi
|
||||||
|
* /credentials:
|
||||||
|
* post:
|
||||||
|
* summary: Create a new credential
|
||||||
|
* description: Creates a new SSH credential for the authenticated user.
|
||||||
|
* tags:
|
||||||
|
* - Credentials
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* name:
|
||||||
|
* type: string
|
||||||
|
* description:
|
||||||
|
* type: string
|
||||||
|
* folder:
|
||||||
|
* type: string
|
||||||
|
* tags:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* authType:
|
||||||
|
* type: string
|
||||||
|
* enum: [password, key]
|
||||||
|
* username:
|
||||||
|
* type: string
|
||||||
|
* password:
|
||||||
|
* type: string
|
||||||
|
* key:
|
||||||
|
* type: string
|
||||||
|
* keyPassword:
|
||||||
|
* type: string
|
||||||
|
* keyType:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 201:
|
||||||
|
* description: Credential created successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid request body.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to create credential.
|
||||||
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
"/",
|
"/",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -231,8 +275,22 @@ router.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get all credentials for the authenticated user
|
/**
|
||||||
// GET /credentials
|
* @openapi
|
||||||
|
* /credentials:
|
||||||
|
* get:
|
||||||
|
* summary: Get all credentials
|
||||||
|
* description: Retrieves all SSH credentials for the authenticated user.
|
||||||
|
* tags:
|
||||||
|
* - Credentials
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: A list of credentials.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid userId.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to fetch credentials.
|
||||||
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
"/",
|
"/",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -264,8 +322,22 @@ router.get(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get all unique credential folders for the authenticated user
|
/**
|
||||||
// GET /credentials/folders
|
* @openapi
|
||||||
|
* /credentials/folders:
|
||||||
|
* get:
|
||||||
|
* summary: Get credential folders
|
||||||
|
* description: Retrieves all unique credential folders for the authenticated user.
|
||||||
|
* tags:
|
||||||
|
* - Credentials
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: A list of folder names.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid userId.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to fetch credential folders.
|
||||||
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
"/folders",
|
"/folders",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -302,8 +374,30 @@ router.get(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get a specific credential by ID (with plain text secrets)
|
/**
|
||||||
// GET /credentials/:id
|
* @openapi
|
||||||
|
* /credentials/{id}:
|
||||||
|
* get:
|
||||||
|
* summary: Get a specific credential
|
||||||
|
* description: Retrieves a specific credential by its ID, including secrets.
|
||||||
|
* tags:
|
||||||
|
* - Credentials
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: The requested credential.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid request.
|
||||||
|
* 404:
|
||||||
|
* description: Credential not found.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to fetch credential.
|
||||||
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
"/:id",
|
"/:id",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -366,8 +460,41 @@ router.get(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update a credential
|
/**
|
||||||
// PUT /credentials/:id
|
* @openapi
|
||||||
|
* /credentials/{id}:
|
||||||
|
* put:
|
||||||
|
* summary: Update a credential
|
||||||
|
* description: Updates a specific credential by its ID.
|
||||||
|
* tags:
|
||||||
|
* - Credentials
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* name:
|
||||||
|
* type: string
|
||||||
|
* description:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: The updated credential.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid request.
|
||||||
|
* 404:
|
||||||
|
* description: Credential not found.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to update credential.
|
||||||
|
*/
|
||||||
router.put(
|
router.put(
|
||||||
"/:id",
|
"/:id",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -510,8 +637,30 @@ router.put(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Delete a credential
|
/**
|
||||||
// DELETE /credentials/:id
|
* @openapi
|
||||||
|
* /credentials/{id}:
|
||||||
|
* delete:
|
||||||
|
* summary: Delete a credential
|
||||||
|
* description: Deletes a specific credential by its ID.
|
||||||
|
* tags:
|
||||||
|
* - Credentials
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Credential deleted successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid request.
|
||||||
|
* 404:
|
||||||
|
* description: Credential not found.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to delete credential.
|
||||||
|
*/
|
||||||
router.delete(
|
router.delete(
|
||||||
"/:id",
|
"/:id",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -626,8 +775,35 @@ router.delete(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Apply a credential to an SSH host (for quick application)
|
/**
|
||||||
// POST /credentials/:id/apply-to-host/:hostId
|
* @openapi
|
||||||
|
* /credentials/{id}/apply-to-host/{hostId}:
|
||||||
|
* post:
|
||||||
|
* summary: Apply a credential to a host
|
||||||
|
* description: Applies a credential to an SSH host for quick application.
|
||||||
|
* tags:
|
||||||
|
* - Credentials
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* - in: path
|
||||||
|
* name: hostId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Credential applied to host successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid request.
|
||||||
|
* 404:
|
||||||
|
* description: Credential not found.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to apply credential to host.
|
||||||
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
"/:id/apply-to-host/:hostId",
|
"/:id/apply-to-host/:hostId",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -705,8 +881,28 @@ router.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get hosts using a specific credential
|
/**
|
||||||
// GET /credentials/:id/hosts
|
* @openapi
|
||||||
|
* /credentials/{id}/hosts:
|
||||||
|
* get:
|
||||||
|
* summary: Get hosts using a credential
|
||||||
|
* description: Retrieves a list of hosts that are using a specific credential.
|
||||||
|
* tags:
|
||||||
|
* - Credentials
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: A list of hosts.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid request.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to fetch hosts using credential.
|
||||||
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
"/:id/hosts",
|
"/:id/hosts",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -800,8 +996,33 @@ function formatSSHHostOutput(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rename a credential folder
|
/**
|
||||||
// PUT /credentials/folders/rename
|
* @openapi
|
||||||
|
* /credentials/folders/rename:
|
||||||
|
* put:
|
||||||
|
* summary: Rename a credential folder
|
||||||
|
* description: Renames a credential folder for the authenticated user.
|
||||||
|
* tags:
|
||||||
|
* - Credentials
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* oldName:
|
||||||
|
* type: string
|
||||||
|
* newName:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Folder renamed successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Both oldName and newName are required.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to rename folder.
|
||||||
|
*/
|
||||||
router.put(
|
router.put(
|
||||||
"/folders/rename",
|
"/folders/rename",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -840,8 +1061,33 @@ router.put(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Detect SSH key type endpoint
|
/**
|
||||||
// POST /credentials/detect-key-type
|
* @openapi
|
||||||
|
* /credentials/detect-key-type:
|
||||||
|
* post:
|
||||||
|
* summary: Detect SSH key type
|
||||||
|
* description: Detects the type of an SSH private key.
|
||||||
|
* tags:
|
||||||
|
* - Credentials
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* privateKey:
|
||||||
|
* type: string
|
||||||
|
* keyPassword:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Key type detection result.
|
||||||
|
* 400:
|
||||||
|
* description: Private key is required.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to detect key type.
|
||||||
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
"/detect-key-type",
|
"/detect-key-type",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -874,8 +1120,31 @@ router.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Detect SSH public key type endpoint
|
/**
|
||||||
// POST /credentials/detect-public-key-type
|
* @openapi
|
||||||
|
* /credentials/detect-public-key-type:
|
||||||
|
* post:
|
||||||
|
* summary: Detect SSH public key type
|
||||||
|
* description: Detects the type of an SSH public key.
|
||||||
|
* tags:
|
||||||
|
* - Credentials
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* publicKey:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Key type detection result.
|
||||||
|
* 400:
|
||||||
|
* description: Public key is required.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to detect public key type.
|
||||||
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
"/detect-public-key-type",
|
"/detect-public-key-type",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -909,8 +1178,35 @@ router.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Validate SSH key pair endpoint
|
/**
|
||||||
// POST /credentials/validate-key-pair
|
* @openapi
|
||||||
|
* /credentials/validate-key-pair:
|
||||||
|
* post:
|
||||||
|
* summary: Validate SSH key pair
|
||||||
|
* description: Validates if a given SSH private key and public key match.
|
||||||
|
* tags:
|
||||||
|
* - Credentials
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* privateKey:
|
||||||
|
* type: string
|
||||||
|
* publicKey:
|
||||||
|
* type: string
|
||||||
|
* keyPassword:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Key pair validation result.
|
||||||
|
* 400:
|
||||||
|
* description: Private key and public key are required.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to validate key pair.
|
||||||
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
"/validate-key-pair",
|
"/validate-key-pair",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -953,8 +1249,32 @@ router.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Generate new SSH key pair endpoint
|
/**
|
||||||
// POST /credentials/generate-key-pair
|
* @openapi
|
||||||
|
* /credentials/generate-key-pair:
|
||||||
|
* post:
|
||||||
|
* summary: Generate new SSH key pair
|
||||||
|
* description: Generates a new SSH key pair.
|
||||||
|
* tags:
|
||||||
|
* - Credentials
|
||||||
|
* requestBody:
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* keyType:
|
||||||
|
* type: string
|
||||||
|
* keySize:
|
||||||
|
* type: integer
|
||||||
|
* passphrase:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: The new key pair.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to generate SSH key pair.
|
||||||
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
"/generate-key-pair",
|
"/generate-key-pair",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -996,8 +1316,33 @@ router.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Generate public key from private key endpoint
|
/**
|
||||||
// POST /credentials/generate-public-key
|
* @openapi
|
||||||
|
* /credentials/generate-public-key:
|
||||||
|
* post:
|
||||||
|
* summary: Generate public key from private key
|
||||||
|
* description: Generates a public key from a given private key.
|
||||||
|
* tags:
|
||||||
|
* - Credentials
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* privateKey:
|
||||||
|
* type: string
|
||||||
|
* keyPassword:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: The generated public key.
|
||||||
|
* 400:
|
||||||
|
* description: Private key is required.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to generate public key.
|
||||||
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
"/generate-public-key",
|
"/generate-public-key",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -1283,7 +1628,7 @@ async function deploySSHKeyToHost(
|
|||||||
.replace(/'/g, "'\\''");
|
.replace(/'/g, "'\\''");
|
||||||
|
|
||||||
conn.exec(
|
conn.exec(
|
||||||
`printf '%s\\n' '${escapedKey} ${credData.name}@Termix' >> ~/.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) => {
|
(err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
clearTimeout(addTimeout);
|
clearTimeout(addTimeout);
|
||||||
@@ -1502,8 +1847,41 @@ async function deploySSHKeyToHost(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deploy SSH Key to Host endpoint
|
/**
|
||||||
// POST /credentials/:id/deploy-to-host
|
* @openapi
|
||||||
|
* /credentials/{id}/deploy-to-host:
|
||||||
|
* post:
|
||||||
|
* summary: Deploy SSH key to a host
|
||||||
|
* description: Deploys an SSH public key to a target host's authorized_keys file.
|
||||||
|
* tags:
|
||||||
|
* - Credentials
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* targetHostId:
|
||||||
|
* type: integer
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: SSH key deployed successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Credential ID and target host ID are required.
|
||||||
|
* 401:
|
||||||
|
* description: Authentication required.
|
||||||
|
* 404:
|
||||||
|
* description: Credential or target host not found.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to deploy SSH key.
|
||||||
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
"/:id/deploy-to-host",
|
"/:id/deploy-to-host",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
|||||||
142
src/backend/database/routes/network-topology.ts
Normal file
142
src/backend/database/routes/network-topology.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import express from "express";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { getDb } from "../db/index.js";
|
||||||
|
import { networkTopology } from "../db/schema.js";
|
||||||
|
import { AuthManager } from "../../utils/auth-manager.js";
|
||||||
|
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const authManager = AuthManager.getInstance();
|
||||||
|
const authenticateJWT = authManager.createAuthMiddleware();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /network-topology:
|
||||||
|
* get:
|
||||||
|
* summary: Get network topology
|
||||||
|
* description: Retrieves the network topology for the authenticated user.
|
||||||
|
* tags:
|
||||||
|
* - Network Topology
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: The network topology.
|
||||||
|
* 401:
|
||||||
|
* description: User not authenticated.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to fetch network topology.
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
authenticateJWT,
|
||||||
|
async (req: express.Request, res: express.Response) => {
|
||||||
|
try {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
if (!userId) {
|
||||||
|
return res.status(401).json({ error: "User not authenticated" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const result = await db
|
||||||
|
.select()
|
||||||
|
.from(networkTopology)
|
||||||
|
.where(eq(networkTopology.userId, userId));
|
||||||
|
|
||||||
|
if (result.length > 0) {
|
||||||
|
const topologyStr = result[0].topology;
|
||||||
|
const topology = topologyStr ? JSON.parse(topologyStr) : null;
|
||||||
|
return res.json(topology);
|
||||||
|
} else {
|
||||||
|
return res.json(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching network topology:", error);
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({
|
||||||
|
error: "Failed to fetch network topology",
|
||||||
|
details: (error as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /network-topology:
|
||||||
|
* post:
|
||||||
|
* summary: Save network topology
|
||||||
|
* description: Saves the network topology for the authenticated user.
|
||||||
|
* tags:
|
||||||
|
* - Network Topology
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* topology:
|
||||||
|
* type: object
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Network topology saved successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Topology data is required.
|
||||||
|
* 401:
|
||||||
|
* description: User not authenticated.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to save network topology.
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/",
|
||||||
|
authenticateJWT,
|
||||||
|
async (req: express.Request, res: express.Response) => {
|
||||||
|
try {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
if (!userId) {
|
||||||
|
return res.status(401).json({ error: "User not authenticated" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { topology } = req.body;
|
||||||
|
if (!topology) {
|
||||||
|
return res.status(400).json({ error: "Topology data is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
// Ensure topology is a string
|
||||||
|
const topologyStr =
|
||||||
|
typeof topology === "string" ? topology : JSON.stringify(topology);
|
||||||
|
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(networkTopology)
|
||||||
|
.where(eq(networkTopology.userId, userId));
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
// Update existing record
|
||||||
|
await db
|
||||||
|
.update(networkTopology)
|
||||||
|
.set({ topology: topologyStr })
|
||||||
|
.where(eq(networkTopology.userId, userId));
|
||||||
|
} else {
|
||||||
|
// Insert new record
|
||||||
|
await db
|
||||||
|
.insert(networkTopology)
|
||||||
|
.values({ userId, topology: topologyStr });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error saving network topology:", error);
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({
|
||||||
|
error: "Failed to save network topology",
|
||||||
|
details: (error as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -27,8 +27,51 @@ function isNonEmptyString(value: unknown): value is string {
|
|||||||
return typeof value === "string" && value.trim().length > 0;
|
return typeof value === "string" && value.trim().length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
//Share a host with a user or role
|
/**
|
||||||
//POST /rbac/host/:id/share
|
* @openapi
|
||||||
|
* /rbac/host/{id}/share:
|
||||||
|
* post:
|
||||||
|
* summary: Share a host
|
||||||
|
* description: Shares a host with a user or a role.
|
||||||
|
* tags:
|
||||||
|
* - RBAC
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* targetType:
|
||||||
|
* type: string
|
||||||
|
* enum: [user, role]
|
||||||
|
* targetUserId:
|
||||||
|
* type: string
|
||||||
|
* targetRoleId:
|
||||||
|
* type: integer
|
||||||
|
* durationHours:
|
||||||
|
* type: number
|
||||||
|
* permissionLevel:
|
||||||
|
* type: string
|
||||||
|
* enum: [view]
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Host shared successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid request body.
|
||||||
|
* 403:
|
||||||
|
* description: Not host owner.
|
||||||
|
* 404:
|
||||||
|
* description: Target user or role not found.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to share host.
|
||||||
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
"/host/:id/share",
|
"/host/:id/share",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -227,8 +270,35 @@ router.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Revoke host access
|
/**
|
||||||
// DELETE /rbac/host/:id/access/:accessId
|
* @openapi
|
||||||
|
* /rbac/host/{id}/access/{accessId}:
|
||||||
|
* delete:
|
||||||
|
* summary: Revoke host access
|
||||||
|
* description: Revokes a user's or role's access to a host.
|
||||||
|
* tags:
|
||||||
|
* - RBAC
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* - in: path
|
||||||
|
* name: accessId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Access revoked successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid ID.
|
||||||
|
* 403:
|
||||||
|
* description: Not host owner.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to revoke access.
|
||||||
|
*/
|
||||||
router.delete(
|
router.delete(
|
||||||
"/host/:id/access/:accessId",
|
"/host/:id/access/:accessId",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -267,8 +337,30 @@ router.delete(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get host access list
|
/**
|
||||||
// GET /rbac/host/:id/access
|
* @openapi
|
||||||
|
* /rbac/host/{id}/access:
|
||||||
|
* get:
|
||||||
|
* summary: Get host access list
|
||||||
|
* description: Retrieves the list of users and roles that have access to a host.
|
||||||
|
* tags:
|
||||||
|
* - RBAC
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: The access list for the host.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid host ID.
|
||||||
|
* 403:
|
||||||
|
* description: Not host owner.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to get access list.
|
||||||
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
"/host/:id/access",
|
"/host/:id/access",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -338,8 +430,20 @@ router.get(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get user's shared hosts (hosts shared WITH this user)
|
/**
|
||||||
// GET /rbac/shared-hosts
|
* @openapi
|
||||||
|
* /rbac/shared-hosts:
|
||||||
|
* get:
|
||||||
|
* summary: Get shared hosts
|
||||||
|
* description: Retrieves the list of hosts that have been shared with the authenticated user.
|
||||||
|
* tags:
|
||||||
|
* - RBAC
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: A list of shared hosts.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to get shared hosts.
|
||||||
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
"/shared-hosts",
|
"/shared-hosts",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -385,8 +489,20 @@ router.get(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get all roles
|
/**
|
||||||
// GET /rbac/roles
|
* @openapi
|
||||||
|
* /rbac/roles:
|
||||||
|
* get:
|
||||||
|
* summary: Get all roles
|
||||||
|
* description: Retrieves a list of all roles.
|
||||||
|
* tags:
|
||||||
|
* - RBAC
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: A list of roles.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to get roles.
|
||||||
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
"/roles",
|
"/roles",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -413,8 +529,20 @@ router.get(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get all roles
|
/**
|
||||||
// GET /rbac/roles
|
* @openapi
|
||||||
|
* /rbac/roles:
|
||||||
|
* get:
|
||||||
|
* summary: Get all roles
|
||||||
|
* description: Retrieves a list of all roles.
|
||||||
|
* tags:
|
||||||
|
* - RBAC
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: A list of roles.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to get roles.
|
||||||
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
"/roles",
|
"/roles",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -443,8 +571,37 @@ router.get(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create new role
|
/**
|
||||||
// POST /rbac/roles
|
* @openapi
|
||||||
|
* /rbac/roles:
|
||||||
|
* post:
|
||||||
|
* summary: Create a new role
|
||||||
|
* description: Creates a new role.
|
||||||
|
* tags:
|
||||||
|
* - RBAC
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* name:
|
||||||
|
* type: string
|
||||||
|
* displayName:
|
||||||
|
* type: string
|
||||||
|
* description:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 201:
|
||||||
|
* description: Role created successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid request body.
|
||||||
|
* 409:
|
||||||
|
* description: A role with this name already exists.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to create role.
|
||||||
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
"/roles",
|
"/roles",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -503,8 +660,41 @@ router.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update role
|
/**
|
||||||
// PUT /rbac/roles/:id
|
* @openapi
|
||||||
|
* /rbac/roles/{id}:
|
||||||
|
* put:
|
||||||
|
* summary: Update a role
|
||||||
|
* description: Updates a role by its ID.
|
||||||
|
* tags:
|
||||||
|
* - RBAC
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* displayName:
|
||||||
|
* type: string
|
||||||
|
* description:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Role updated successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid request body or role ID.
|
||||||
|
* 404:
|
||||||
|
* description: Role not found.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to update role.
|
||||||
|
*/
|
||||||
router.put(
|
router.put(
|
||||||
"/roles/:id",
|
"/roles/:id",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -570,8 +760,32 @@ router.put(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Delete role
|
/**
|
||||||
// DELETE /rbac/roles/:id
|
* @openapi
|
||||||
|
* /rbac/roles/{id}:
|
||||||
|
* delete:
|
||||||
|
* summary: Delete a role
|
||||||
|
* description: Deletes a role by its ID.
|
||||||
|
* tags:
|
||||||
|
* - RBAC
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Role deleted successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid role ID.
|
||||||
|
* 403:
|
||||||
|
* description: Cannot delete system roles.
|
||||||
|
* 404:
|
||||||
|
* description: Role not found.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to delete role.
|
||||||
|
*/
|
||||||
router.delete(
|
router.delete(
|
||||||
"/roles/:id",
|
"/roles/:id",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -634,8 +848,43 @@ router.delete(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assign role to user
|
/**
|
||||||
// POST /rbac/users/:userId/roles
|
* @openapi
|
||||||
|
* /rbac/users/{userId}/roles:
|
||||||
|
* post:
|
||||||
|
* summary: Assign a role to a user
|
||||||
|
* description: Assigns a role to a user.
|
||||||
|
* tags:
|
||||||
|
* - RBAC
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: userId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* roleId:
|
||||||
|
* type: integer
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Role assigned successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Role ID is required.
|
||||||
|
* 403:
|
||||||
|
* description: System roles cannot be manually assigned.
|
||||||
|
* 404:
|
||||||
|
* description: User or role not found.
|
||||||
|
* 409:
|
||||||
|
* description: Role already assigned.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to assign role.
|
||||||
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
"/users/:userId/roles",
|
"/users/:userId/roles",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -746,8 +995,37 @@ router.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Remove role from user
|
/**
|
||||||
// DELETE /rbac/users/:userId/roles/:roleId
|
* @openapi
|
||||||
|
* /rbac/users/{userId}/roles/{roleId}:
|
||||||
|
* delete:
|
||||||
|
* summary: Remove a role from a user
|
||||||
|
* description: Removes a role from a user.
|
||||||
|
* tags:
|
||||||
|
* - RBAC
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: userId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* - in: path
|
||||||
|
* name: roleId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Role removed successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid role ID.
|
||||||
|
* 403:
|
||||||
|
* description: System roles cannot be removed.
|
||||||
|
* 404:
|
||||||
|
* description: Role not found.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to remove role.
|
||||||
|
*/
|
||||||
router.delete(
|
router.delete(
|
||||||
"/users/:userId/roles/:roleId",
|
"/users/:userId/roles/:roleId",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -805,8 +1083,28 @@ router.delete(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get user's roles
|
/**
|
||||||
// GET /rbac/users/:userId/roles
|
* @openapi
|
||||||
|
* /rbac/users/{userId}/roles:
|
||||||
|
* get:
|
||||||
|
* summary: Get user's roles
|
||||||
|
* description: Retrieves a list of roles for a specific user.
|
||||||
|
* tags:
|
||||||
|
* - RBAC
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: userId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: A list of roles.
|
||||||
|
* 403:
|
||||||
|
* description: Access denied.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to get user roles.
|
||||||
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
"/users/:userId/roles",
|
"/users/:userId/roles",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
|||||||
@@ -17,8 +17,22 @@ const authManager = AuthManager.getInstance();
|
|||||||
const authenticateJWT = authManager.createAuthMiddleware();
|
const authenticateJWT = authManager.createAuthMiddleware();
|
||||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||||
|
|
||||||
// Get all snippet folders
|
/**
|
||||||
// GET /snippets/folders
|
* @openapi
|
||||||
|
* /snippets/folders:
|
||||||
|
* get:
|
||||||
|
* summary: Get all snippet folders
|
||||||
|
* description: Retrieves all snippet folders for the authenticated user.
|
||||||
|
* tags:
|
||||||
|
* - Snippets
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: A list of snippet folders.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid userId.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to fetch snippet folders.
|
||||||
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
"/folders",
|
"/folders",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -46,8 +60,37 @@ router.get(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create a new snippet folder
|
/**
|
||||||
// POST /snippets/folders
|
* @openapi
|
||||||
|
* /snippets/folders:
|
||||||
|
* post:
|
||||||
|
* summary: Create a new snippet folder
|
||||||
|
* description: Creates a new snippet folder for the authenticated user.
|
||||||
|
* tags:
|
||||||
|
* - Snippets
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* name:
|
||||||
|
* type: string
|
||||||
|
* color:
|
||||||
|
* type: string
|
||||||
|
* icon:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 201:
|
||||||
|
* description: Snippet folder created successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Folder name is required.
|
||||||
|
* 409:
|
||||||
|
* description: Folder with this name already exists.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to create snippet folder.
|
||||||
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
"/folders",
|
"/folders",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -110,8 +153,41 @@ router.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update snippet folder metadata (color, icon)
|
/**
|
||||||
// PUT /snippets/folders/:name/metadata
|
* @openapi
|
||||||
|
* /snippets/folders/{name}/metadata:
|
||||||
|
* put:
|
||||||
|
* summary: Update snippet folder metadata
|
||||||
|
* description: Updates the metadata (color, icon) of a snippet folder.
|
||||||
|
* tags:
|
||||||
|
* - Snippets
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: name
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* color:
|
||||||
|
* type: string
|
||||||
|
* icon:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Snippet folder metadata updated successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid request.
|
||||||
|
* 404:
|
||||||
|
* description: Folder not found.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to update snippet folder metadata.
|
||||||
|
*/
|
||||||
router.put(
|
router.put(
|
||||||
"/folders/:name/metadata",
|
"/folders/:name/metadata",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -194,8 +270,37 @@ router.put(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Rename snippet folder
|
/**
|
||||||
// PUT /snippets/folders/rename
|
* @openapi
|
||||||
|
* /snippets/folders/rename:
|
||||||
|
* put:
|
||||||
|
* summary: Rename a snippet folder
|
||||||
|
* description: Renames a snippet folder for the authenticated user.
|
||||||
|
* tags:
|
||||||
|
* - Snippets
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* oldName:
|
||||||
|
* type: string
|
||||||
|
* newName:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Folder renamed successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid request.
|
||||||
|
* 404:
|
||||||
|
* description: Folder not found.
|
||||||
|
* 409:
|
||||||
|
* description: Folder with new name already exists.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to rename snippet folder.
|
||||||
|
*/
|
||||||
router.put(
|
router.put(
|
||||||
"/folders/rename",
|
"/folders/rename",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -282,8 +387,28 @@ router.put(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Delete snippet folder
|
/**
|
||||||
// DELETE /snippets/folders/:name
|
* @openapi
|
||||||
|
* /snippets/folders/{name}:
|
||||||
|
* delete:
|
||||||
|
* summary: Delete a snippet folder
|
||||||
|
* description: Deletes a snippet folder and moves its snippets to the root.
|
||||||
|
* tags:
|
||||||
|
* - Snippets
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: name
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Snippet folder deleted successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid request.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to delete snippet folder.
|
||||||
|
*/
|
||||||
router.delete(
|
router.delete(
|
||||||
"/folders/:name",
|
"/folders/:name",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -338,8 +463,40 @@ router.delete(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reorder snippets (bulk update)
|
/**
|
||||||
// PUT /snippets/reorder
|
* @openapi
|
||||||
|
* /snippets/reorder:
|
||||||
|
* put:
|
||||||
|
* summary: Reorder snippets
|
||||||
|
* description: Bulk updates the order and folder of snippets.
|
||||||
|
* tags:
|
||||||
|
* - Snippets
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* snippets:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* id:
|
||||||
|
* type: integer
|
||||||
|
* order:
|
||||||
|
* type: integer
|
||||||
|
* folder:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Snippets reordered successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid request.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to reorder snippets.
|
||||||
|
*/
|
||||||
router.put(
|
router.put(
|
||||||
"/reorder",
|
"/reorder",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -405,8 +562,35 @@ router.put(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Execute a snippet on a host
|
/**
|
||||||
// POST /snippets/execute
|
* @openapi
|
||||||
|
* /snippets/execute:
|
||||||
|
* post:
|
||||||
|
* summary: Execute a snippet on a host
|
||||||
|
* description: Executes a snippet on a specified host.
|
||||||
|
* tags:
|
||||||
|
* - Snippets
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* snippetId:
|
||||||
|
* type: integer
|
||||||
|
* hostId:
|
||||||
|
* type: integer
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Snippet executed successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Snippet ID and Host ID are required.
|
||||||
|
* 404:
|
||||||
|
* description: Snippet or host not found.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to execute snippet.
|
||||||
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
"/execute",
|
"/execute",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -662,8 +846,22 @@ router.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get all snippets for the authenticated user
|
/**
|
||||||
// GET /snippets
|
* @openapi
|
||||||
|
* /snippets:
|
||||||
|
* get:
|
||||||
|
* summary: Get all snippets
|
||||||
|
* description: Retrieves all snippets for the authenticated user.
|
||||||
|
* tags:
|
||||||
|
* - Snippets
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: A list of snippets.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid userId.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to fetch snippets.
|
||||||
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
"/",
|
"/",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -696,8 +894,30 @@ router.get(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get a specific snippet by ID
|
/**
|
||||||
// GET /snippets/:id
|
* @openapi
|
||||||
|
* /snippets/{id}:
|
||||||
|
* get:
|
||||||
|
* summary: Get a specific snippet
|
||||||
|
* description: Retrieves a specific snippet by its ID.
|
||||||
|
* tags:
|
||||||
|
* - Snippets
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: The requested snippet.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid request parameters.
|
||||||
|
* 404:
|
||||||
|
* description: Snippet not found.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to fetch snippet.
|
||||||
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
"/:id",
|
"/:id",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -735,8 +955,39 @@ router.get(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create a new snippet
|
/**
|
||||||
// POST /snippets
|
* @openapi
|
||||||
|
* /snippets:
|
||||||
|
* post:
|
||||||
|
* summary: Create a new snippet
|
||||||
|
* description: Creates a new snippet for the authenticated user.
|
||||||
|
* tags:
|
||||||
|
* - Snippets
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* name:
|
||||||
|
* type: string
|
||||||
|
* content:
|
||||||
|
* type: string
|
||||||
|
* description:
|
||||||
|
* type: string
|
||||||
|
* folder:
|
||||||
|
* type: string
|
||||||
|
* order:
|
||||||
|
* type: integer
|
||||||
|
* responses:
|
||||||
|
* 201:
|
||||||
|
* description: Snippet created successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Name and content are required.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to create snippet.
|
||||||
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
"/",
|
"/",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -806,8 +1057,47 @@ router.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update a snippet
|
/**
|
||||||
// PUT /snippets/:id
|
* @openapi
|
||||||
|
* /snippets/{id}:
|
||||||
|
* put:
|
||||||
|
* summary: Update a snippet
|
||||||
|
* description: Updates a specific snippet by its ID.
|
||||||
|
* tags:
|
||||||
|
* - Snippets
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* name:
|
||||||
|
* type: string
|
||||||
|
* content:
|
||||||
|
* type: string
|
||||||
|
* description:
|
||||||
|
* type: string
|
||||||
|
* folder:
|
||||||
|
* type: string
|
||||||
|
* order:
|
||||||
|
* type: integer
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: The updated snippet.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid request.
|
||||||
|
* 404:
|
||||||
|
* description: Snippet not found.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to update snippet.
|
||||||
|
*/
|
||||||
router.put(
|
router.put(
|
||||||
"/:id",
|
"/:id",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -883,8 +1173,30 @@ router.put(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Delete a snippet
|
/**
|
||||||
// DELETE /snippets/:id
|
* @openapi
|
||||||
|
* /snippets/{id}:
|
||||||
|
* delete:
|
||||||
|
* summary: Delete a snippet
|
||||||
|
* description: Deletes a specific snippet by its ID.
|
||||||
|
* tags:
|
||||||
|
* - Snippets
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Snippet deleted successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid request.
|
||||||
|
* 404:
|
||||||
|
* description: Snippet not found.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to delete snippet.
|
||||||
|
*/
|
||||||
router.delete(
|
router.delete(
|
||||||
"/:id",
|
"/:id",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -17,8 +17,33 @@ const authManager = AuthManager.getInstance();
|
|||||||
const authenticateJWT = authManager.createAuthMiddleware();
|
const authenticateJWT = authManager.createAuthMiddleware();
|
||||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||||
|
|
||||||
// Save command to history
|
/**
|
||||||
// POST /terminal/command_history
|
* @openapi
|
||||||
|
* /terminal/command_history:
|
||||||
|
* post:
|
||||||
|
* summary: Save command to history
|
||||||
|
* description: Saves a command to the command history for a specific host.
|
||||||
|
* tags:
|
||||||
|
* - Terminal
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* hostId:
|
||||||
|
* type: integer
|
||||||
|
* command:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 201:
|
||||||
|
* description: Command saved successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Missing required parameters.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to save command.
|
||||||
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
"/command_history",
|
"/command_history",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -59,8 +84,28 @@ router.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get command history for a specific host
|
/**
|
||||||
// GET /terminal/command_history/:hostId
|
* @openapi
|
||||||
|
* /terminal/command_history/{hostId}:
|
||||||
|
* get:
|
||||||
|
* summary: Get command history
|
||||||
|
* description: Retrieves the command history for a specific host.
|
||||||
|
* tags:
|
||||||
|
* - Terminal
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: hostId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: A list of commands.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid request parameters.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to fetch history.
|
||||||
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
"/command_history/:hostId",
|
"/command_history/:hostId",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -107,8 +152,33 @@ router.get(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Delete a specific command from history
|
/**
|
||||||
// POST /terminal/command_history/delete
|
* @openapi
|
||||||
|
* /terminal/command_history/delete:
|
||||||
|
* post:
|
||||||
|
* summary: Delete a specific command from history
|
||||||
|
* description: Deletes a specific command from the history of a host.
|
||||||
|
* tags:
|
||||||
|
* - Terminal
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* hostId:
|
||||||
|
* type: integer
|
||||||
|
* command:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Command deleted successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Missing required parameters.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to delete command.
|
||||||
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
"/command_history/delete",
|
"/command_history/delete",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -150,8 +220,28 @@ router.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Clear command history for a specific host (optional feature)
|
/**
|
||||||
// DELETE /terminal/command_history/:hostId
|
* @openapi
|
||||||
|
* /terminal/command_history/{hostId}:
|
||||||
|
* delete:
|
||||||
|
* summary: Clear command history
|
||||||
|
* description: Clears the entire command history for a specific host.
|
||||||
|
* tags:
|
||||||
|
* - Terminal
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: hostId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Command history cleared successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid request.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to clear history.
|
||||||
|
*/
|
||||||
router.delete(
|
router.delete(
|
||||||
"/command_history/:hostId",
|
"/command_history/:hostId",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -365,7 +365,34 @@ app.use(express.urlencoded({ limit: "100mb", extended: true }));
|
|||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
app.use(authManager.createAuthMiddleware());
|
app.use(authManager.createAuthMiddleware());
|
||||||
|
|
||||||
// POST /docker/ssh/connect - Establish SSH session
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /docker/ssh/connect:
|
||||||
|
* post:
|
||||||
|
* summary: Establish SSH session for Docker
|
||||||
|
* description: Establishes an SSH session to a host for Docker operations.
|
||||||
|
* tags:
|
||||||
|
* - Docker
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: SSH connection established.
|
||||||
|
* 400:
|
||||||
|
* description: Missing sessionId or hostId.
|
||||||
|
* 401:
|
||||||
|
* description: Authentication required.
|
||||||
|
* 403:
|
||||||
|
* description: Docker is not enabled for this host.
|
||||||
|
* 404:
|
||||||
|
* description: Host not found.
|
||||||
|
* 500:
|
||||||
|
* description: SSH connection failed.
|
||||||
|
*/
|
||||||
app.post("/docker/ssh/connect", async (req, res) => {
|
app.post("/docker/ssh/connect", async (req, res) => {
|
||||||
const {
|
const {
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -929,7 +956,29 @@ app.post("/docker/ssh/connect", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /docker/ssh/disconnect - Close SSH session
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /docker/ssh/disconnect:
|
||||||
|
* post:
|
||||||
|
* summary: Disconnect SSH session
|
||||||
|
* description: Closes an active SSH session for Docker operations.
|
||||||
|
* tags:
|
||||||
|
* - Docker
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* sessionId:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: SSH session disconnected.
|
||||||
|
* 400:
|
||||||
|
* description: Session ID is required.
|
||||||
|
*/
|
||||||
app.post("/docker/ssh/disconnect", async (req, res) => {
|
app.post("/docker/ssh/disconnect", async (req, res) => {
|
||||||
const { sessionId } = req.body;
|
const { sessionId } = req.body;
|
||||||
|
|
||||||
@@ -942,7 +991,35 @@ app.post("/docker/ssh/disconnect", async (req, res) => {
|
|||||||
res.json({ success: true, message: "SSH session disconnected" });
|
res.json({ success: true, message: "SSH session disconnected" });
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /docker/ssh/connect-totp - Verify TOTP and complete connection
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /docker/ssh/connect-totp:
|
||||||
|
* post:
|
||||||
|
* summary: Verify TOTP and complete connection
|
||||||
|
* description: Verifies the TOTP code and completes the SSH connection.
|
||||||
|
* tags:
|
||||||
|
* - Docker
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* sessionId:
|
||||||
|
* type: string
|
||||||
|
* totpCode:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: TOTP verified, SSH connection established.
|
||||||
|
* 400:
|
||||||
|
* description: Session ID and TOTP code required.
|
||||||
|
* 401:
|
||||||
|
* description: Invalid TOTP code.
|
||||||
|
* 404:
|
||||||
|
* description: TOTP session expired.
|
||||||
|
*/
|
||||||
app.post("/docker/ssh/connect-totp", async (req, res) => {
|
app.post("/docker/ssh/connect-totp", async (req, res) => {
|
||||||
const { sessionId, totpCode } = req.body;
|
const { sessionId, totpCode } = req.body;
|
||||||
const userId = (req as any).userId;
|
const userId = (req as any).userId;
|
||||||
@@ -1105,7 +1182,29 @@ app.post("/docker/ssh/connect-totp", async (req, res) => {
|
|||||||
session.finish(responses);
|
session.finish(responses);
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /docker/ssh/keepalive - Keep session alive
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /docker/ssh/keepalive:
|
||||||
|
* post:
|
||||||
|
* summary: Keep SSH session alive
|
||||||
|
* description: Keeps an active SSH session alive.
|
||||||
|
* tags:
|
||||||
|
* - Docker
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* sessionId:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Session keepalive successful.
|
||||||
|
* 400:
|
||||||
|
* description: Session ID is required or session not found.
|
||||||
|
*/
|
||||||
app.post("/docker/ssh/keepalive", async (req, res) => {
|
app.post("/docker/ssh/keepalive", async (req, res) => {
|
||||||
const { sessionId } = req.body;
|
const { sessionId } = req.body;
|
||||||
|
|
||||||
@@ -1133,7 +1232,26 @@ app.post("/docker/ssh/keepalive", async (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /docker/ssh/status - Check session status
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /docker/ssh/status:
|
||||||
|
* get:
|
||||||
|
* summary: Check SSH session status
|
||||||
|
* description: Checks the status of an active SSH session.
|
||||||
|
* tags:
|
||||||
|
* - Docker
|
||||||
|
* parameters:
|
||||||
|
* - in: query
|
||||||
|
* name: sessionId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Session status.
|
||||||
|
* 400:
|
||||||
|
* description: Session ID is required.
|
||||||
|
*/
|
||||||
app.get("/docker/ssh/status", async (req, res) => {
|
app.get("/docker/ssh/status", async (req, res) => {
|
||||||
const sessionId = req.query.sessionId as string;
|
const sessionId = req.query.sessionId as string;
|
||||||
|
|
||||||
@@ -1146,7 +1264,28 @@ app.get("/docker/ssh/status", async (req, res) => {
|
|||||||
res.json({ success: true, connected: isConnected });
|
res.json({ success: true, connected: isConnected });
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /docker/validate/:sessionId - Validate Docker availability
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /docker/validate/{sessionId}:
|
||||||
|
* get:
|
||||||
|
* summary: Validate Docker availability
|
||||||
|
* description: Validates if Docker is available on the host.
|
||||||
|
* tags:
|
||||||
|
* - Docker
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: sessionId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Docker availability status.
|
||||||
|
* 400:
|
||||||
|
* description: SSH session not found or not connected.
|
||||||
|
* 500:
|
||||||
|
* description: Validation failed.
|
||||||
|
*/
|
||||||
app.get("/docker/validate/:sessionId", async (req, res) => {
|
app.get("/docker/validate/:sessionId", async (req, res) => {
|
||||||
const { sessionId } = req.params;
|
const { sessionId } = req.params;
|
||||||
const userId = (req as any).userId;
|
const userId = (req as any).userId;
|
||||||
@@ -1236,7 +1375,32 @@ app.get("/docker/validate/:sessionId", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /docker/containers/:sessionId - List all containers
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /docker/containers/{sessionId}:
|
||||||
|
* get:
|
||||||
|
* summary: List all containers
|
||||||
|
* description: Lists all Docker containers on the host.
|
||||||
|
* tags:
|
||||||
|
* - Docker
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: sessionId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* - in: query
|
||||||
|
* name: all
|
||||||
|
* schema:
|
||||||
|
* type: boolean
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: A list of containers.
|
||||||
|
* 400:
|
||||||
|
* description: SSH session not found or not connected.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to list containers.
|
||||||
|
*/
|
||||||
app.get("/docker/containers/:sessionId", async (req, res) => {
|
app.get("/docker/containers/:sessionId", async (req, res) => {
|
||||||
const { sessionId } = req.params;
|
const { sessionId } = req.params;
|
||||||
const all = req.query.all !== "false";
|
const all = req.query.all !== "false";
|
||||||
@@ -1297,7 +1461,35 @@ app.get("/docker/containers/:sessionId", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /docker/containers/:sessionId/:containerId - Get container details
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /docker/containers/{sessionId}/{containerId}:
|
||||||
|
* get:
|
||||||
|
* summary: Get container details
|
||||||
|
* description: Retrieves detailed information about a specific container.
|
||||||
|
* tags:
|
||||||
|
* - Docker
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: sessionId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* - in: path
|
||||||
|
* name: containerId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Container details.
|
||||||
|
* 400:
|
||||||
|
* description: SSH session not found or not connected.
|
||||||
|
* 404:
|
||||||
|
* description: Container not found.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to get container details.
|
||||||
|
*/
|
||||||
app.get("/docker/containers/:sessionId/:containerId", async (req, res) => {
|
app.get("/docker/containers/:sessionId/:containerId", async (req, res) => {
|
||||||
const { sessionId, containerId } = req.params;
|
const { sessionId, containerId } = req.params;
|
||||||
const userId = (req as any).userId;
|
const userId = (req as any).userId;
|
||||||
@@ -1356,7 +1548,35 @@ app.get("/docker/containers/:sessionId/:containerId", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /docker/containers/:sessionId/:containerId/start - Start container
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /docker/containers/{sessionId}/{containerId}/start:
|
||||||
|
* post:
|
||||||
|
* summary: Start container
|
||||||
|
* description: Starts a specific container.
|
||||||
|
* tags:
|
||||||
|
* - Docker
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: sessionId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* - in: path
|
||||||
|
* name: containerId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Container started successfully.
|
||||||
|
* 400:
|
||||||
|
* description: SSH session not found or not connected.
|
||||||
|
* 404:
|
||||||
|
* description: Container not found.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to start container.
|
||||||
|
*/
|
||||||
app.post(
|
app.post(
|
||||||
"/docker/containers/:sessionId/:containerId/start",
|
"/docker/containers/:sessionId/:containerId/start",
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
@@ -1414,7 +1634,35 @@ app.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// POST /docker/containers/:sessionId/:containerId/stop - Stop container
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /docker/containers/{sessionId}/{containerId}/stop:
|
||||||
|
* post:
|
||||||
|
* summary: Stop container
|
||||||
|
* description: Stops a specific container.
|
||||||
|
* tags:
|
||||||
|
* - Docker
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: sessionId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* - in: path
|
||||||
|
* name: containerId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Container stopped successfully.
|
||||||
|
* 400:
|
||||||
|
* description: SSH session not found or not connected.
|
||||||
|
* 404:
|
||||||
|
* description: Container not found.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to stop container.
|
||||||
|
*/
|
||||||
app.post(
|
app.post(
|
||||||
"/docker/containers/:sessionId/:containerId/stop",
|
"/docker/containers/:sessionId/:containerId/stop",
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
@@ -1472,7 +1720,35 @@ app.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// POST /docker/containers/:sessionId/:containerId/restart - Restart container
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /docker/containers/{sessionId}/{containerId}/restart:
|
||||||
|
* post:
|
||||||
|
* summary: Restart container
|
||||||
|
* description: Restarts a specific container.
|
||||||
|
* tags:
|
||||||
|
* - Docker
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: sessionId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* - in: path
|
||||||
|
* name: containerId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Container restarted successfully.
|
||||||
|
* 400:
|
||||||
|
* description: SSH session not found or not connected.
|
||||||
|
* 404:
|
||||||
|
* description: Container not found.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to restart container.
|
||||||
|
*/
|
||||||
app.post(
|
app.post(
|
||||||
"/docker/containers/:sessionId/:containerId/restart",
|
"/docker/containers/:sessionId/:containerId/restart",
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
@@ -1530,7 +1806,35 @@ app.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// POST /docker/containers/:sessionId/:containerId/pause - Pause container
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /docker/containers/{sessionId}/{containerId}/pause:
|
||||||
|
* post:
|
||||||
|
* summary: Pause container
|
||||||
|
* description: Pauses a specific container.
|
||||||
|
* tags:
|
||||||
|
* - Docker
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: sessionId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* - in: path
|
||||||
|
* name: containerId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Container paused successfully.
|
||||||
|
* 400:
|
||||||
|
* description: SSH session not found or not connected.
|
||||||
|
* 404:
|
||||||
|
* description: Container not found.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to pause container.
|
||||||
|
*/
|
||||||
app.post(
|
app.post(
|
||||||
"/docker/containers/:sessionId/:containerId/pause",
|
"/docker/containers/:sessionId/:containerId/pause",
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
@@ -1588,7 +1892,35 @@ app.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// POST /docker/containers/:sessionId/:containerId/unpause - Unpause container
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /docker/containers/{sessionId}/{containerId}/unpause:
|
||||||
|
* post:
|
||||||
|
* summary: Unpause container
|
||||||
|
* description: Unpauses a specific container.
|
||||||
|
* tags:
|
||||||
|
* - Docker
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: sessionId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* - in: path
|
||||||
|
* name: containerId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Container unpaused successfully.
|
||||||
|
* 400:
|
||||||
|
* description: SSH session not found or not connected.
|
||||||
|
* 404:
|
||||||
|
* description: Container not found.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to unpause container.
|
||||||
|
*/
|
||||||
app.post(
|
app.post(
|
||||||
"/docker/containers/:sessionId/:containerId/unpause",
|
"/docker/containers/:sessionId/:containerId/unpause",
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
@@ -1646,7 +1978,39 @@ app.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// DELETE /docker/containers/:sessionId/:containerId/remove - Remove container
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /docker/containers/{sessionId}/{containerId}/remove:
|
||||||
|
* delete:
|
||||||
|
* summary: Remove container
|
||||||
|
* description: Removes a specific container.
|
||||||
|
* tags:
|
||||||
|
* - Docker
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: sessionId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* - in: path
|
||||||
|
* name: containerId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* - in: query
|
||||||
|
* name: force
|
||||||
|
* schema:
|
||||||
|
* type: boolean
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Container removed successfully.
|
||||||
|
* 400:
|
||||||
|
* description: SSH session not found or not connected, or cannot remove a running container.
|
||||||
|
* 404:
|
||||||
|
* description: Container not found.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to remove container.
|
||||||
|
*/
|
||||||
app.delete(
|
app.delete(
|
||||||
"/docker/containers/:sessionId/:containerId/remove",
|
"/docker/containers/:sessionId/:containerId/remove",
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
@@ -1718,7 +2082,51 @@ app.delete(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// GET /docker/containers/:sessionId/:containerId/logs - Get container logs
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /docker/containers/{sessionId}/{containerId}/logs:
|
||||||
|
* get:
|
||||||
|
* summary: Get container logs
|
||||||
|
* description: Retrieves logs for a specific container.
|
||||||
|
* tags:
|
||||||
|
* - Docker
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: sessionId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* - in: path
|
||||||
|
* name: containerId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* - in: query
|
||||||
|
* name: tail
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* - in: query
|
||||||
|
* name: timestamps
|
||||||
|
* schema:
|
||||||
|
* type: boolean
|
||||||
|
* - in: query
|
||||||
|
* name: since
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* - in: query
|
||||||
|
* name: until
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Container logs.
|
||||||
|
* 400:
|
||||||
|
* description: SSH session not found or not connected.
|
||||||
|
* 404:
|
||||||
|
* description: Container not found.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to get container logs.
|
||||||
|
*/
|
||||||
app.get("/docker/containers/:sessionId/:containerId/logs", async (req, res) => {
|
app.get("/docker/containers/:sessionId/:containerId/logs", async (req, res) => {
|
||||||
const { sessionId, containerId } = req.params;
|
const { sessionId, containerId } = req.params;
|
||||||
const tail = req.query.tail ? parseInt(req.query.tail as string) : 100;
|
const tail = req.query.tail ? parseInt(req.query.tail as string) : 100;
|
||||||
@@ -1795,7 +2203,35 @@ app.get("/docker/containers/:sessionId/:containerId/logs", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /docker/containers/:sessionId/:containerId/stats - Get container stats
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /docker/containers/{sessionId}/{containerId}/stats:
|
||||||
|
* get:
|
||||||
|
* summary: Get container stats
|
||||||
|
* description: Retrieves stats for a specific container.
|
||||||
|
* tags:
|
||||||
|
* - Docker
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: sessionId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* - in: path
|
||||||
|
* name: containerId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Container stats.
|
||||||
|
* 400:
|
||||||
|
* description: SSH session not found or not connected.
|
||||||
|
* 404:
|
||||||
|
* description: Container not found.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to get container stats.
|
||||||
|
*/
|
||||||
app.get(
|
app.get(
|
||||||
"/docker/containers/:sessionId/:containerId/stats",
|
"/docker/containers/:sessionId/:containerId/stats",
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,8 @@ import { collectUptimeMetrics } from "./widgets/uptime-collector.js";
|
|||||||
import { collectProcessesMetrics } from "./widgets/processes-collector.js";
|
import { collectProcessesMetrics } from "./widgets/processes-collector.js";
|
||||||
import { collectSystemMetrics } from "./widgets/system-collector.js";
|
import { collectSystemMetrics } from "./widgets/system-collector.js";
|
||||||
import { collectLoginStats } from "./widgets/login-stats-collector.js";
|
import { collectLoginStats } from "./widgets/login-stats-collector.js";
|
||||||
|
import { collectPortsMetrics } from "./widgets/ports-collector.js";
|
||||||
|
import { collectFirewallMetrics } from "./widgets/firewall-collector.js";
|
||||||
import { createSocks5Connection } from "../utils/socks5-helper.js";
|
import { createSocks5Connection } from "../utils/socks5-helper.js";
|
||||||
|
|
||||||
async function resolveJumpHost(
|
async function resolveJumpHost(
|
||||||
@@ -1782,6 +1784,62 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
|||||||
login_stats = await collectLoginStats(client);
|
login_stats = await collectLoginStats(client);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
|
let ports: {
|
||||||
|
source: "ss" | "netstat" | "none";
|
||||||
|
ports: Array<{
|
||||||
|
protocol: "tcp" | "udp";
|
||||||
|
localAddress: string;
|
||||||
|
localPort: number;
|
||||||
|
state?: string;
|
||||||
|
pid?: number;
|
||||||
|
process?: string;
|
||||||
|
}>;
|
||||||
|
} = {
|
||||||
|
source: "none",
|
||||||
|
ports: [],
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
ports = await collectPortsMetrics(client);
|
||||||
|
} catch (e) {
|
||||||
|
statsLogger.debug("Failed to collect ports metrics", {
|
||||||
|
operation: "ports_metrics_failed",
|
||||||
|
error: e instanceof Error ? e.message : String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let firewall: {
|
||||||
|
type: "iptables" | "nftables" | "none";
|
||||||
|
status: "active" | "inactive" | "unknown";
|
||||||
|
chains: Array<{
|
||||||
|
name: string;
|
||||||
|
policy: string;
|
||||||
|
rules: Array<{
|
||||||
|
chain: string;
|
||||||
|
target: string;
|
||||||
|
protocol: string;
|
||||||
|
source: string;
|
||||||
|
destination: string;
|
||||||
|
dport?: string;
|
||||||
|
sport?: string;
|
||||||
|
state?: string;
|
||||||
|
interface?: string;
|
||||||
|
extra?: string;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
} = {
|
||||||
|
type: "none",
|
||||||
|
status: "unknown",
|
||||||
|
chains: [],
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
firewall = await collectFirewallMetrics(client);
|
||||||
|
} catch (e) {
|
||||||
|
statsLogger.debug("Failed to collect firewall metrics", {
|
||||||
|
operation: "firewall_metrics_failed",
|
||||||
|
error: e instanceof Error ? e.message : String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
cpu,
|
cpu,
|
||||||
memory,
|
memory,
|
||||||
@@ -1791,6 +1849,8 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
|||||||
processes,
|
processes,
|
||||||
system,
|
system,
|
||||||
login_stats,
|
login_stats,
|
||||||
|
ports,
|
||||||
|
firewall,
|
||||||
};
|
};
|
||||||
|
|
||||||
metricsCache.set(host.id, result);
|
metricsCache.set(host.id, result);
|
||||||
@@ -1864,6 +1924,20 @@ function tcpPing(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /status:
|
||||||
|
* get:
|
||||||
|
* summary: Get all host statuses
|
||||||
|
* description: Retrieves the status of all hosts for the authenticated user.
|
||||||
|
* tags:
|
||||||
|
* - Server Stats
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: A map of host IDs to their status entries.
|
||||||
|
* 401:
|
||||||
|
* description: Session expired - please log in again.
|
||||||
|
*/
|
||||||
app.get("/status", async (req, res) => {
|
app.get("/status", async (req, res) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
|
||||||
@@ -1886,6 +1960,28 @@ app.get("/status", async (req, res) => {
|
|||||||
res.json(result);
|
res.json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /status/{id}:
|
||||||
|
* get:
|
||||||
|
* summary: Get host status by ID
|
||||||
|
* description: Retrieves the status of a specific host by its ID.
|
||||||
|
* tags:
|
||||||
|
* - Server Stats
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Host status entry.
|
||||||
|
* 401:
|
||||||
|
* description: Session expired - please log in again.
|
||||||
|
* 404:
|
||||||
|
* description: Status not available.
|
||||||
|
*/
|
||||||
app.get("/status/:id", validateHostId, async (req, res) => {
|
app.get("/status/:id", validateHostId, async (req, res) => {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -1910,6 +2006,20 @@ app.get("/status/:id", validateHostId, async (req, res) => {
|
|||||||
res.json(statusEntry);
|
res.json(statusEntry);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /clear-connections:
|
||||||
|
* post:
|
||||||
|
* summary: Clear all SSH connections
|
||||||
|
* description: Clears all SSH connections from the connection pool.
|
||||||
|
* tags:
|
||||||
|
* - Server Stats
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: All SSH connections cleared.
|
||||||
|
* 401:
|
||||||
|
* description: Session expired - please log in again.
|
||||||
|
*/
|
||||||
app.post("/clear-connections", async (req, res) => {
|
app.post("/clear-connections", async (req, res) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
|
||||||
@@ -1924,6 +2034,20 @@ app.post("/clear-connections", async (req, res) => {
|
|||||||
res.json({ message: "All SSH connections cleared" });
|
res.json({ message: "All SSH connections cleared" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /refresh:
|
||||||
|
* post:
|
||||||
|
* summary: Refresh polling
|
||||||
|
* description: Clears all SSH connections and refreshes host polling.
|
||||||
|
* tags:
|
||||||
|
* - Server Stats
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Polling refreshed.
|
||||||
|
* 401:
|
||||||
|
* description: Session expired - please log in again.
|
||||||
|
*/
|
||||||
app.post("/refresh", async (req, res) => {
|
app.post("/refresh", async (req, res) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
|
||||||
@@ -1940,6 +2064,35 @@ app.post("/refresh", async (req, res) => {
|
|||||||
res.json({ message: "Polling refreshed" });
|
res.json({ message: "Polling refreshed" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /host-updated:
|
||||||
|
* post:
|
||||||
|
* summary: Start polling for updated host
|
||||||
|
* description: Starts polling for a specific host after it has been updated.
|
||||||
|
* tags:
|
||||||
|
* - Server Stats
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* hostId:
|
||||||
|
* type: integer
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Host polling started.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid hostId.
|
||||||
|
* 401:
|
||||||
|
* description: Session expired - please log in again.
|
||||||
|
* 404:
|
||||||
|
* description: Host not found.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to start polling.
|
||||||
|
*/
|
||||||
app.post("/host-updated", async (req, res) => {
|
app.post("/host-updated", async (req, res) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const { hostId } = req.body;
|
const { hostId } = req.body;
|
||||||
@@ -1975,6 +2128,33 @@ app.post("/host-updated", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /host-deleted:
|
||||||
|
* post:
|
||||||
|
* summary: Stop polling for deleted host
|
||||||
|
* description: Stops polling for a specific host after it has been deleted.
|
||||||
|
* tags:
|
||||||
|
* - Server Stats
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* hostId:
|
||||||
|
* type: integer
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Host polling stopped.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid hostId.
|
||||||
|
* 401:
|
||||||
|
* description: Session expired - please log in again.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to stop polling.
|
||||||
|
*/
|
||||||
app.post("/host-deleted", async (req, res) => {
|
app.post("/host-deleted", async (req, res) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const { hostId } = req.body;
|
const { hostId } = req.body;
|
||||||
@@ -2003,6 +2183,28 @@ app.post("/host-deleted", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /metrics/{id}:
|
||||||
|
* get:
|
||||||
|
* summary: Get host metrics
|
||||||
|
* description: Retrieves current metrics for a specific host including CPU, memory, disk, network, processes, and system information.
|
||||||
|
* tags:
|
||||||
|
* - Server Stats
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Host metrics data.
|
||||||
|
* 401:
|
||||||
|
* description: Session expired - please log in again.
|
||||||
|
* 404:
|
||||||
|
* description: Metrics not available.
|
||||||
|
*/
|
||||||
app.get("/metrics/:id", validateHostId, async (req, res) => {
|
app.get("/metrics/:id", validateHostId, async (req, res) => {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -2040,6 +2242,30 @@ app.get("/metrics/:id", validateHostId, async (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /metrics/start/{id}:
|
||||||
|
* post:
|
||||||
|
* summary: Start metrics collection
|
||||||
|
* description: Establishes an SSH connection and starts collecting metrics for a specific host.
|
||||||
|
* tags:
|
||||||
|
* - Server Stats
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Metrics collection started successfully, or TOTP required.
|
||||||
|
* 401:
|
||||||
|
* description: Session expired - please log in again.
|
||||||
|
* 404:
|
||||||
|
* description: Host not found.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to start metrics collection.
|
||||||
|
*/
|
||||||
app.post("/metrics/start/:id", validateHostId, async (req, res) => {
|
app.post("/metrics/start/:id", validateHostId, async (req, res) => {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -2219,6 +2445,37 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /metrics/stop/{id}:
|
||||||
|
* post:
|
||||||
|
* summary: Stop metrics collection
|
||||||
|
* description: Stops metrics collection for a specific host and cleans up the SSH session.
|
||||||
|
* tags:
|
||||||
|
* - Server Stats
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* requestBody:
|
||||||
|
* required: false
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* viewerSessionId:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Metrics collection stopped successfully.
|
||||||
|
* 401:
|
||||||
|
* description: Session expired - please log in again.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to stop metrics collection.
|
||||||
|
*/
|
||||||
app.post("/metrics/stop/:id", validateHostId, async (req, res) => {
|
app.post("/metrics/stop/:id", validateHostId, async (req, res) => {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -2261,6 +2518,37 @@ app.post("/metrics/stop/:id", validateHostId, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /metrics/connect-totp:
|
||||||
|
* post:
|
||||||
|
* summary: Complete TOTP verification for metrics
|
||||||
|
* description: Verifies the TOTP code and completes the metrics SSH connection.
|
||||||
|
* tags:
|
||||||
|
* - Server Stats
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* sessionId:
|
||||||
|
* type: string
|
||||||
|
* totpCode:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: TOTP verified, metrics connection established.
|
||||||
|
* 400:
|
||||||
|
* description: Missing sessionId or totpCode.
|
||||||
|
* 401:
|
||||||
|
* description: Session expired or invalid TOTP code.
|
||||||
|
* 404:
|
||||||
|
* description: TOTP session not found or expired.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to verify TOTP.
|
||||||
|
*/
|
||||||
app.post("/metrics/connect-totp", async (req, res) => {
|
app.post("/metrics/connect-totp", async (req, res) => {
|
||||||
const { sessionId, totpCode } = req.body;
|
const { sessionId, totpCode } = req.body;
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -2396,6 +2684,35 @@ app.post("/metrics/connect-totp", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /metrics/heartbeat:
|
||||||
|
* post:
|
||||||
|
* summary: Update viewer heartbeat
|
||||||
|
* description: Updates the heartbeat timestamp for a metrics viewer session to keep it alive.
|
||||||
|
* tags:
|
||||||
|
* - Server Stats
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* viewerSessionId:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Heartbeat updated successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid viewerSessionId.
|
||||||
|
* 401:
|
||||||
|
* description: Session expired - please log in again.
|
||||||
|
* 404:
|
||||||
|
* description: Viewer session not found.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to update heartbeat.
|
||||||
|
*/
|
||||||
app.post("/metrics/heartbeat", async (req, res) => {
|
app.post("/metrics/heartbeat", async (req, res) => {
|
||||||
const { viewerSessionId } = req.body;
|
const { viewerSessionId } = req.body;
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -2428,6 +2745,33 @@ app.post("/metrics/heartbeat", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /metrics/register-viewer:
|
||||||
|
* post:
|
||||||
|
* summary: Register metrics viewer
|
||||||
|
* description: Registers a new viewer session for a host to track who is viewing metrics.
|
||||||
|
* tags:
|
||||||
|
* - Server Stats
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* hostId:
|
||||||
|
* type: integer
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Viewer registered successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid hostId.
|
||||||
|
* 401:
|
||||||
|
* description: Session expired - please log in again.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to register viewer.
|
||||||
|
*/
|
||||||
app.post("/metrics/register-viewer", async (req, res) => {
|
app.post("/metrics/register-viewer", async (req, res) => {
|
||||||
const { hostId } = req.body;
|
const { hostId } = req.body;
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -2458,6 +2802,35 @@ app.post("/metrics/register-viewer", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /metrics/unregister-viewer:
|
||||||
|
* post:
|
||||||
|
* summary: Unregister metrics viewer
|
||||||
|
* description: Unregisters a viewer session when they stop viewing metrics for a host.
|
||||||
|
* tags:
|
||||||
|
* - Server Stats
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* hostId:
|
||||||
|
* type: integer
|
||||||
|
* viewerSessionId:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Viewer unregistered successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid hostId or viewerSessionId.
|
||||||
|
* 401:
|
||||||
|
* description: Session expired - please log in again.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to unregister viewer.
|
||||||
|
*/
|
||||||
app.post("/metrics/unregister-viewer", async (req, res) => {
|
app.post("/metrics/unregister-viewer", async (req, res) => {
|
||||||
const { hostId, viewerSessionId } = req.body;
|
const { hostId, viewerSessionId } = req.body;
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
|||||||
@@ -648,7 +648,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
);
|
);
|
||||||
cleanupSSH(connectionTimeout);
|
cleanupSSH(connectionTimeout);
|
||||||
}
|
}
|
||||||
}, 30000);
|
}, 120000);
|
||||||
|
|
||||||
let resolvedCredentials = { password, key, keyPassword, keyType, authType };
|
let resolvedCredentials = { password, key, keyPassword, keyType, authType };
|
||||||
let authMethodNotAvailable = false;
|
let authMethodNotAvailable = false;
|
||||||
@@ -761,6 +761,36 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sshLogger.info("Creating shell", {
|
||||||
|
operation: "ssh_shell_start",
|
||||||
|
hostId: id,
|
||||||
|
ip,
|
||||||
|
port,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
|
||||||
|
let shellCallbackReceived = false;
|
||||||
|
const shellTimeout = setTimeout(() => {
|
||||||
|
if (!shellCallbackReceived && isShellInitializing) {
|
||||||
|
sshLogger.error("Shell creation timeout - no response from server", {
|
||||||
|
operation: "ssh_shell_timeout",
|
||||||
|
hostId: id,
|
||||||
|
ip,
|
||||||
|
port,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
isShellInitializing = false;
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "error",
|
||||||
|
message:
|
||||||
|
"Shell creation timeout. The server may not support interactive shells or the connection was interrupted.",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
cleanupSSH(connectionTimeout);
|
||||||
|
}
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
conn.shell(
|
conn.shell(
|
||||||
{
|
{
|
||||||
rows: data.rows,
|
rows: data.rows,
|
||||||
@@ -768,6 +798,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
term: "xterm-256color",
|
term: "xterm-256color",
|
||||||
} as PseudoTtyOptions,
|
} as PseudoTtyOptions,
|
||||||
(err, stream) => {
|
(err, stream) => {
|
||||||
|
shellCallbackReceived = true;
|
||||||
|
clearTimeout(shellTimeout);
|
||||||
isShellInitializing = false;
|
isShellInitializing = false;
|
||||||
|
|
||||||
if (err) {
|
if (err) {
|
||||||
@@ -784,6 +816,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
message: "Shell error: " + err.message,
|
message: "Shell error: " + err.message,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
cleanupSSH(connectionTimeout);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -969,6 +1002,31 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
|
|
||||||
sshConn.on("close", () => {
|
sshConn.on("close", () => {
|
||||||
clearTimeout(connectionTimeout);
|
clearTimeout(connectionTimeout);
|
||||||
|
if (isShellInitializing || (isConnected && !sshStream)) {
|
||||||
|
sshLogger.warn("SSH connection closed during shell initialization", {
|
||||||
|
operation: "ssh_close_during_init",
|
||||||
|
hostId: id,
|
||||||
|
ip,
|
||||||
|
port,
|
||||||
|
username,
|
||||||
|
isShellInitializing,
|
||||||
|
hasStream: !!sshStream,
|
||||||
|
});
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "error",
|
||||||
|
message:
|
||||||
|
"Connection closed during shell initialization. The server may have rejected the shell request.",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else if (!sshStream) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "disconnected",
|
||||||
|
message: "Connection closed",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
cleanupSSH(connectionTimeout);
|
cleanupSSH(connectionTimeout);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1115,10 +1173,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
tryKeyboard: true,
|
tryKeyboard: true,
|
||||||
keepaliveInterval: 30000,
|
keepaliveInterval: 30000,
|
||||||
keepaliveCountMax: 3,
|
keepaliveCountMax: 3,
|
||||||
readyTimeout: 30000,
|
readyTimeout: 120000,
|
||||||
tcpKeepAlive: true,
|
tcpKeepAlive: true,
|
||||||
tcpKeepAliveInitialDelay: 30000,
|
tcpKeepAliveInitialDelay: 30000,
|
||||||
timeout: 30000,
|
timeout: 120000,
|
||||||
env: {
|
env: {
|
||||||
TERM: "xterm-256color",
|
TERM: "xterm-256color",
|
||||||
LANG: "en_US.UTF-8",
|
LANG: "en_US.UTF-8",
|
||||||
|
|||||||
@@ -828,15 +828,22 @@ async function connectSSHTunnel(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tunnelType = tunnelConfig.tunnelType || "remote";
|
||||||
|
const tunnelFlag = tunnelType === "local" ? "-L" : "-R";
|
||||||
|
const portMapping =
|
||||||
|
tunnelType === "local"
|
||||||
|
? `${tunnelConfig.sourcePort}:${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort}`
|
||||||
|
: `${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort}`;
|
||||||
|
|
||||||
let tunnelCmd: string;
|
let tunnelCmd: string;
|
||||||
if (
|
if (
|
||||||
resolvedEndpointCredentials.authMethod === "key" &&
|
resolvedEndpointCredentials.authMethod === "key" &&
|
||||||
resolvedEndpointCredentials.sshKey
|
resolvedEndpointCredentials.sshKey
|
||||||
) {
|
) {
|
||||||
const keyFilePath = `/tmp/tunnel_key_${tunnelName.replace(/[^a-zA-Z0-9]/g, "_")}`;
|
const keyFilePath = `/tmp/tunnel_key_${tunnelName.replace(/[^a-zA-Z0-9]/g, "_")}`;
|
||||||
tunnelCmd = `echo '${resolvedEndpointCredentials.sshKey}' > ${keyFilePath} && chmod 600 ${keyFilePath} && exec -a "${tunnelMarker}" ssh -i ${keyFilePath} -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o GatewayPorts=yes -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} && rm -f ${keyFilePath}`;
|
tunnelCmd = `echo '${resolvedEndpointCredentials.sshKey}' > ${keyFilePath} && chmod 600 ${keyFilePath} && exec -a "${tunnelMarker}" ssh -i ${keyFilePath} -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o GatewayPorts=yes ${tunnelFlag} ${portMapping} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} && rm -f ${keyFilePath}`;
|
||||||
} else {
|
} else {
|
||||||
tunnelCmd = `exec -a "${tunnelMarker}" sshpass -p '${resolvedEndpointCredentials.password || ""}' ssh -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o GatewayPorts=yes -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}`;
|
tunnelCmd = `exec -a "${tunnelMarker}" sshpass -p '${resolvedEndpointCredentials.password || ""}' ssh -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o GatewayPorts=yes ${tunnelFlag} ${portMapping} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
conn.exec(tunnelCmd, (err, stream) => {
|
conn.exec(tunnelCmd, (err, stream) => {
|
||||||
@@ -1302,7 +1309,9 @@ async function killRemoteTunnelByMarker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
conn.on("ready", () => {
|
conn.on("ready", () => {
|
||||||
const checkCmd = `ps aux | grep -E '(${tunnelMarker}|ssh.*-R.*${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort}.*${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}|sshpass.*ssh.*-R.*${tunnelConfig.endpointPort})' | grep -v grep`;
|
const tunnelType = tunnelConfig.tunnelType || "remote";
|
||||||
|
const tunnelFlag = tunnelType === "local" ? "-L" : "-R";
|
||||||
|
const checkCmd = `ps aux | grep -E '(${tunnelMarker}|ssh.*${tunnelFlag}.*${tunnelConfig.endpointPort}:.*:${tunnelConfig.sourcePort}.*${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}|sshpass.*ssh.*${tunnelFlag})' | grep -v grep`;
|
||||||
|
|
||||||
conn.exec(checkCmd, (_err, stream) => {
|
conn.exec(checkCmd, (_err, stream) => {
|
||||||
let foundProcesses = false;
|
let foundProcesses = false;
|
||||||
@@ -1323,8 +1332,8 @@ async function killRemoteTunnelByMarker(
|
|||||||
|
|
||||||
const killCmds = [
|
const killCmds = [
|
||||||
`pkill -TERM -f '${tunnelMarker}'`,
|
`pkill -TERM -f '${tunnelMarker}'`,
|
||||||
`sleep 1 && pkill -f 'ssh.*-R.*${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort}.*${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}'`,
|
`sleep 1 && pkill -f 'ssh.*${tunnelFlag}.*${tunnelConfig.endpointPort}:.*:${tunnelConfig.sourcePort}.*${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}'`,
|
||||||
`sleep 1 && pkill -f 'sshpass.*ssh.*-R.*${tunnelConfig.endpointPort}'`,
|
`sleep 1 && pkill -f 'sshpass.*ssh.*${tunnelFlag}.*${tunnelConfig.endpointPort}'`,
|
||||||
`sleep 2 && pkill -9 -f '${tunnelMarker}'`,
|
`sleep 2 && pkill -9 -f '${tunnelMarker}'`,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -1450,10 +1459,42 @@ async function killRemoteTunnelByMarker(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /ssh/tunnel/status:
|
||||||
|
* get:
|
||||||
|
* summary: Get all tunnel statuses
|
||||||
|
* description: Retrieves the status of all SSH tunnels.
|
||||||
|
* tags:
|
||||||
|
* - SSH Tunnels
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: A list of all tunnel statuses.
|
||||||
|
*/
|
||||||
app.get("/ssh/tunnel/status", (req, res) => {
|
app.get("/ssh/tunnel/status", (req, res) => {
|
||||||
res.json(getAllTunnelStatus());
|
res.json(getAllTunnelStatus());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /ssh/tunnel/status/{tunnelName}:
|
||||||
|
* get:
|
||||||
|
* summary: Get tunnel status by name
|
||||||
|
* description: Retrieves the status of a specific SSH tunnel by its name.
|
||||||
|
* tags:
|
||||||
|
* - SSH Tunnels
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: tunnelName
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Tunnel status.
|
||||||
|
* 404:
|
||||||
|
* description: Tunnel not found.
|
||||||
|
*/
|
||||||
app.get("/ssh/tunnel/status/:tunnelName", (req, res) => {
|
app.get("/ssh/tunnel/status/:tunnelName", (req, res) => {
|
||||||
const { tunnelName } = req.params;
|
const { tunnelName } = req.params;
|
||||||
const status = connectionStatus.get(tunnelName);
|
const status = connectionStatus.get(tunnelName);
|
||||||
@@ -1465,6 +1506,39 @@ app.get("/ssh/tunnel/status/:tunnelName", (req, res) => {
|
|||||||
res.json({ name: tunnelName, status });
|
res.json({ name: tunnelName, status });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /ssh/tunnel/connect:
|
||||||
|
* post:
|
||||||
|
* summary: Connect SSH tunnel
|
||||||
|
* description: Establishes an SSH tunnel connection with the specified configuration.
|
||||||
|
* tags:
|
||||||
|
* - SSH Tunnels
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* name:
|
||||||
|
* type: string
|
||||||
|
* sourceHostId:
|
||||||
|
* type: integer
|
||||||
|
* tunnelIndex:
|
||||||
|
* type: integer
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Connection request received.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid tunnel configuration.
|
||||||
|
* 401:
|
||||||
|
* description: Authentication required.
|
||||||
|
* 403:
|
||||||
|
* description: Access denied to this host.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to connect tunnel.
|
||||||
|
*/
|
||||||
app.post(
|
app.post(
|
||||||
"/ssh/tunnel/connect",
|
"/ssh/tunnel/connect",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -1619,6 +1693,35 @@ app.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /ssh/tunnel/disconnect:
|
||||||
|
* post:
|
||||||
|
* summary: Disconnect SSH tunnel
|
||||||
|
* description: Disconnects an active SSH tunnel.
|
||||||
|
* tags:
|
||||||
|
* - SSH Tunnels
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* tunnelName:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Disconnect request received.
|
||||||
|
* 400:
|
||||||
|
* description: Tunnel name required.
|
||||||
|
* 401:
|
||||||
|
* description: Authentication required.
|
||||||
|
* 403:
|
||||||
|
* description: Access denied.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to disconnect tunnel.
|
||||||
|
*/
|
||||||
app.post(
|
app.post(
|
||||||
"/ssh/tunnel/disconnect",
|
"/ssh/tunnel/disconnect",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -1683,6 +1786,35 @@ app.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /ssh/tunnel/cancel:
|
||||||
|
* post:
|
||||||
|
* summary: Cancel tunnel retry
|
||||||
|
* description: Cancels the retry mechanism for a failed SSH tunnel connection.
|
||||||
|
* tags:
|
||||||
|
* - SSH Tunnels
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* tunnelName:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Cancel request received.
|
||||||
|
* 400:
|
||||||
|
* description: Tunnel name required.
|
||||||
|
* 401:
|
||||||
|
* description: Authentication required.
|
||||||
|
* 403:
|
||||||
|
* description: Access denied.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to cancel tunnel retry.
|
||||||
|
*/
|
||||||
app.post(
|
app.post(
|
||||||
"/ssh/tunnel/cancel",
|
"/ssh/tunnel/cancel",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
@@ -1806,6 +1938,7 @@ async function initializeAutoStartTunnels(): Promise<void> {
|
|||||||
tunnelConnection.endpointHost,
|
tunnelConnection.endpointHost,
|
||||||
tunnelConnection.endpointPort,
|
tunnelConnection.endpointPort,
|
||||||
),
|
),
|
||||||
|
tunnelType: tunnelConnection.tunnelType || "remote",
|
||||||
sourceHostId: host.id,
|
sourceHostId: host.id,
|
||||||
tunnelIndex: tunnelIndex,
|
tunnelIndex: tunnelIndex,
|
||||||
hostName: host.name || `${host.username}@${host.ip}`,
|
hostName: host.name || `${host.username}@${host.ip}`,
|
||||||
|
|||||||
254
src/backend/ssh/widgets/firewall-collector.ts
Normal file
254
src/backend/ssh/widgets/firewall-collector.ts
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import type { Client } from "ssh2";
|
||||||
|
import { execCommand } from "./common-utils.js";
|
||||||
|
import type {
|
||||||
|
FirewallMetrics,
|
||||||
|
FirewallChain,
|
||||||
|
FirewallRule,
|
||||||
|
} from "../../../types/stats-widgets.js";
|
||||||
|
|
||||||
|
function parseIptablesRule(line: string): FirewallRule | null {
|
||||||
|
if (!line.startsWith("-A ")) return null;
|
||||||
|
|
||||||
|
const rule: FirewallRule = {
|
||||||
|
chain: "",
|
||||||
|
target: "",
|
||||||
|
protocol: "all",
|
||||||
|
source: "0.0.0.0/0",
|
||||||
|
destination: "0.0.0.0/0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const chainMatch = line.match(/^-A\s+(\S+)/);
|
||||||
|
if (chainMatch) {
|
||||||
|
rule.chain = chainMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetMatch = line.match(/-j\s+(\S+)/);
|
||||||
|
if (targetMatch) {
|
||||||
|
rule.target = targetMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocolMatch = line.match(/-p\s+(\S+)/);
|
||||||
|
if (protocolMatch) {
|
||||||
|
rule.protocol = protocolMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceMatch = line.match(/-s\s+(\S+)/);
|
||||||
|
if (sourceMatch) {
|
||||||
|
rule.source = sourceMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const destMatch = line.match(/-d\s+(\S+)/);
|
||||||
|
if (destMatch) {
|
||||||
|
rule.destination = destMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const dportMatch = line.match(/--dport\s+(\S+)/);
|
||||||
|
if (dportMatch) {
|
||||||
|
rule.dport = dportMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const sportMatch = line.match(/--sport\s+(\S+)/);
|
||||||
|
if (sportMatch) {
|
||||||
|
rule.sport = sportMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateMatch = line.match(/--state\s+(\S+)/);
|
||||||
|
if (stateMatch) {
|
||||||
|
rule.state = stateMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const interfaceMatch = line.match(/-i\s+(\S+)/);
|
||||||
|
if (interfaceMatch) {
|
||||||
|
rule.interface = interfaceMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIptablesOutput(output: string): FirewallChain[] {
|
||||||
|
const chains: Map<string, FirewallChain> = new Map();
|
||||||
|
const lines = output.split("\n");
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
|
||||||
|
const policyMatch = trimmed.match(/^:(\S+)\s+(\S+)/);
|
||||||
|
if (policyMatch) {
|
||||||
|
const [, chainName, policy] = policyMatch;
|
||||||
|
chains.set(chainName, {
|
||||||
|
name: chainName,
|
||||||
|
policy: policy,
|
||||||
|
rules: [],
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rule = parseIptablesRule(trimmed);
|
||||||
|
if (rule) {
|
||||||
|
let chain = chains.get(rule.chain);
|
||||||
|
if (!chain) {
|
||||||
|
chain = {
|
||||||
|
name: rule.chain,
|
||||||
|
policy: "ACCEPT",
|
||||||
|
rules: [],
|
||||||
|
};
|
||||||
|
chains.set(rule.chain, chain);
|
||||||
|
}
|
||||||
|
chain.rules.push(rule);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(chains.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNftablesOutput(output: string): FirewallChain[] {
|
||||||
|
const chains: FirewallChain[] = [];
|
||||||
|
let currentChain: FirewallChain | null = null;
|
||||||
|
|
||||||
|
const lines = output.split("\n");
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
|
||||||
|
const chainMatch = trimmed.match(
|
||||||
|
/chain\s+(\S+)\s*\{?\s*(?:type\s+\S+\s+hook\s+(\S+))?/,
|
||||||
|
);
|
||||||
|
if (chainMatch) {
|
||||||
|
if (currentChain) {
|
||||||
|
chains.push(currentChain);
|
||||||
|
}
|
||||||
|
currentChain = {
|
||||||
|
name: chainMatch[1].toUpperCase(),
|
||||||
|
policy: "ACCEPT",
|
||||||
|
rules: [],
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentChain && trimmed.startsWith("policy ")) {
|
||||||
|
const policyMatch = trimmed.match(/policy\s+(\S+)/);
|
||||||
|
if (policyMatch) {
|
||||||
|
currentChain.policy = policyMatch[1].toUpperCase();
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentChain && trimmed && !trimmed.startsWith("}")) {
|
||||||
|
const rule: FirewallRule = {
|
||||||
|
chain: currentChain.name,
|
||||||
|
target: "",
|
||||||
|
protocol: "all",
|
||||||
|
source: "0.0.0.0/0",
|
||||||
|
destination: "0.0.0.0/0",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (trimmed.includes("accept")) rule.target = "ACCEPT";
|
||||||
|
else if (trimmed.includes("drop")) rule.target = "DROP";
|
||||||
|
else if (trimmed.includes("reject")) rule.target = "REJECT";
|
||||||
|
|
||||||
|
const tcpMatch = trimmed.match(/tcp\s+dport\s+(\S+)/);
|
||||||
|
if (tcpMatch) {
|
||||||
|
rule.protocol = "tcp";
|
||||||
|
rule.dport = tcpMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const udpMatch = trimmed.match(/udp\s+dport\s+(\S+)/);
|
||||||
|
if (udpMatch) {
|
||||||
|
rule.protocol = "udp";
|
||||||
|
rule.dport = udpMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const saddrMatch = trimmed.match(/saddr\s+(\S+)/);
|
||||||
|
if (saddrMatch) {
|
||||||
|
rule.source = saddrMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const daddrMatch = trimmed.match(/daddr\s+(\S+)/);
|
||||||
|
if (daddrMatch) {
|
||||||
|
rule.destination = daddrMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const iifMatch = trimmed.match(/iif\s+"?(\S+)"?/);
|
||||||
|
if (iifMatch) {
|
||||||
|
rule.interface = iifMatch[1].replace(/"/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctStateMatch = trimmed.match(/ct\s+state\s+(\S+)/);
|
||||||
|
if (ctStateMatch) {
|
||||||
|
rule.state = ctStateMatch[1].toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.target) {
|
||||||
|
currentChain.rules.push(rule);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed === "}") {
|
||||||
|
if (currentChain) {
|
||||||
|
chains.push(currentChain);
|
||||||
|
currentChain = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentChain) {
|
||||||
|
chains.push(currentChain);
|
||||||
|
}
|
||||||
|
|
||||||
|
return chains;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function collectFirewallMetrics(
|
||||||
|
client: Client,
|
||||||
|
): Promise<FirewallMetrics> {
|
||||||
|
try {
|
||||||
|
const iptablesResult = await execCommand(
|
||||||
|
client,
|
||||||
|
"iptables-save 2>/dev/null",
|
||||||
|
15000,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (iptablesResult.stdout && iptablesResult.stdout.includes("*filter")) {
|
||||||
|
const chains = parseIptablesOutput(iptablesResult.stdout);
|
||||||
|
const hasRules = chains.some((c) => c.rules.length > 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "iptables",
|
||||||
|
status: hasRules ? "active" : "inactive",
|
||||||
|
chains: chains.filter(
|
||||||
|
(c) =>
|
||||||
|
c.name === "INPUT" || c.name === "OUTPUT" || c.name === "FORWARD",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const nftResult = await execCommand(
|
||||||
|
client,
|
||||||
|
"nft list ruleset 2>/dev/null",
|
||||||
|
15000,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (nftResult.stdout && nftResult.stdout.trim()) {
|
||||||
|
const chains = parseNftablesOutput(nftResult.stdout);
|
||||||
|
const hasRules = chains.some((c) => c.rules.length > 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "nftables",
|
||||||
|
status: hasRules ? "active" : "inactive",
|
||||||
|
chains,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "none",
|
||||||
|
status: "unknown",
|
||||||
|
chains: [],
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
type: "none",
|
||||||
|
status: "unknown",
|
||||||
|
chains: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
155
src/backend/ssh/widgets/ports-collector.ts
Normal file
155
src/backend/ssh/widgets/ports-collector.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import type { Client } from "ssh2";
|
||||||
|
import { execCommand } from "./common-utils.js";
|
||||||
|
import type { PortsMetrics, ListeningPort } from "../../../types/stats-widgets.js";
|
||||||
|
|
||||||
|
function parseSsOutput(output: string): ListeningPort[] {
|
||||||
|
const ports: ListeningPort[] = [];
|
||||||
|
const lines = output.split("\n").slice(1);
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
|
||||||
|
const parts = trimmed.split(/\s+/);
|
||||||
|
if (parts.length < 5) continue;
|
||||||
|
|
||||||
|
const protocol = parts[0]?.toLowerCase();
|
||||||
|
if (protocol !== "tcp" && protocol !== "udp") continue;
|
||||||
|
|
||||||
|
const state = parts[1];
|
||||||
|
const localAddr = parts[4];
|
||||||
|
|
||||||
|
if (!localAddr) continue;
|
||||||
|
|
||||||
|
const lastColon = localAddr.lastIndexOf(":");
|
||||||
|
if (lastColon === -1) continue;
|
||||||
|
|
||||||
|
const address = localAddr.substring(0, lastColon);
|
||||||
|
const portStr = localAddr.substring(lastColon + 1);
|
||||||
|
const port = parseInt(portStr, 10);
|
||||||
|
|
||||||
|
if (isNaN(port)) continue;
|
||||||
|
|
||||||
|
const portEntry: ListeningPort = {
|
||||||
|
protocol: protocol as "tcp" | "udp",
|
||||||
|
localAddress: address.replace(/^\[|\]$/g, ""),
|
||||||
|
localPort: port,
|
||||||
|
state: protocol === "tcp" ? state : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const processInfo = parts[6];
|
||||||
|
if (processInfo && processInfo.startsWith("users:")) {
|
||||||
|
const pidMatch = processInfo.match(/pid=(\d+)/);
|
||||||
|
const nameMatch = processInfo.match(/\("([^"]+)"/);
|
||||||
|
if (pidMatch) portEntry.pid = parseInt(pidMatch[1], 10);
|
||||||
|
if (nameMatch) portEntry.process = nameMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
ports.push(portEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ports;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNetstatOutput(output: string): ListeningPort[] {
|
||||||
|
const ports: ListeningPort[] = [];
|
||||||
|
const lines = output.split("\n");
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
|
||||||
|
const parts = trimmed.split(/\s+/);
|
||||||
|
if (parts.length < 4) continue;
|
||||||
|
|
||||||
|
const proto = parts[0]?.toLowerCase();
|
||||||
|
if (!proto) continue;
|
||||||
|
|
||||||
|
let protocol: "tcp" | "udp";
|
||||||
|
if (proto.startsWith("tcp")) {
|
||||||
|
protocol = "tcp";
|
||||||
|
} else if (proto.startsWith("udp")) {
|
||||||
|
protocol = "udp";
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localAddr = parts[3];
|
||||||
|
if (!localAddr) continue;
|
||||||
|
|
||||||
|
const lastColon = localAddr.lastIndexOf(":");
|
||||||
|
if (lastColon === -1) continue;
|
||||||
|
|
||||||
|
const address = localAddr.substring(0, lastColon);
|
||||||
|
const portStr = localAddr.substring(lastColon + 1);
|
||||||
|
const port = parseInt(portStr, 10);
|
||||||
|
|
||||||
|
if (isNaN(port)) continue;
|
||||||
|
|
||||||
|
const portEntry: ListeningPort = {
|
||||||
|
protocol,
|
||||||
|
localAddress: address,
|
||||||
|
localPort: port,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (protocol === "tcp" && parts.length >= 6) {
|
||||||
|
portEntry.state = parts[5];
|
||||||
|
}
|
||||||
|
|
||||||
|
const pidProgram = parts[parts.length - 1];
|
||||||
|
if (pidProgram && pidProgram.includes("/")) {
|
||||||
|
const [pidStr, process] = pidProgram.split("/");
|
||||||
|
const pid = parseInt(pidStr, 10);
|
||||||
|
if (!isNaN(pid)) portEntry.pid = pid;
|
||||||
|
if (process) portEntry.process = process;
|
||||||
|
}
|
||||||
|
|
||||||
|
ports.push(portEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ports;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function collectPortsMetrics(
|
||||||
|
client: Client,
|
||||||
|
): Promise<PortsMetrics> {
|
||||||
|
try {
|
||||||
|
const ssResult = await execCommand(
|
||||||
|
client,
|
||||||
|
"ss -tulnp 2>/dev/null",
|
||||||
|
15000,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ssResult.stdout && ssResult.stdout.includes("Local")) {
|
||||||
|
const ports = parseSsOutput(ssResult.stdout);
|
||||||
|
return {
|
||||||
|
source: "ss",
|
||||||
|
ports: ports.sort((a, b) => a.localPort - b.localPort),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const netstatResult = await execCommand(
|
||||||
|
client,
|
||||||
|
"netstat -tulnp 2>/dev/null",
|
||||||
|
15000,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (netstatResult.stdout && netstatResult.stdout.includes("Local")) {
|
||||||
|
const ports = parseNetstatOutput(netstatResult.stdout);
|
||||||
|
return {
|
||||||
|
source: "netstat",
|
||||||
|
ports: ports.sort((a, b) => a.localPort - b.localPort),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: "none",
|
||||||
|
ports: [],
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
source: "none",
|
||||||
|
ports: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
145
src/backend/swagger.ts
Normal file
145
src/backend/swagger.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import swaggerJSDoc from "swagger-jsdoc";
|
||||||
|
import path from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { promises as fs } from "fs";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
const projectRoot = path.join(__dirname, "..", "..", "..");
|
||||||
|
|
||||||
|
const swaggerOptions: swaggerJSDoc.Options = {
|
||||||
|
definition: {
|
||||||
|
openapi: "3.0.3",
|
||||||
|
info: {
|
||||||
|
title: "Termix API",
|
||||||
|
version: "0.0.0",
|
||||||
|
description: "Termix Backend API Reference",
|
||||||
|
},
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
url: "http://localhost:30001",
|
||||||
|
description: "Main database and authentication server",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "http://localhost:30003",
|
||||||
|
description: "SSH tunnel management server",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "http://localhost:30004",
|
||||||
|
description: "SSH file manager server",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "http://localhost:30005",
|
||||||
|
description: "Server statistics and monitoring server",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "http://localhost:30006",
|
||||||
|
description: "Dashboard server",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "http://localhost:30007",
|
||||||
|
description: "Docker management server",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
components: {
|
||||||
|
securitySchemes: {
|
||||||
|
bearerAuth: {
|
||||||
|
type: "http",
|
||||||
|
scheme: "bearer",
|
||||||
|
bearerFormat: "JWT",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
schemas: {
|
||||||
|
Error: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
error: { type: "string" },
|
||||||
|
details: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
bearerAuth: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tags: [
|
||||||
|
{
|
||||||
|
name: "Alerts",
|
||||||
|
description: "System alerts and notifications management",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Credentials",
|
||||||
|
description: "SSH credential management",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Network Topology",
|
||||||
|
description: "Network topology visualization and management",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "RBAC",
|
||||||
|
description: "Role-based access control for host sharing",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Snippets",
|
||||||
|
description: "Command snippet management",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Terminal",
|
||||||
|
description: "Terminal command history",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Users",
|
||||||
|
description: "User management and authentication",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Dashboard",
|
||||||
|
description: "Dashboard statistics and activity",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Docker",
|
||||||
|
description: "Docker container management",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SSH Tunnels",
|
||||||
|
description: "SSH tunnel connection management",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Server Stats",
|
||||||
|
description: "Server status monitoring and metrics collection",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "File Manager",
|
||||||
|
description: "SSH file management operations",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
apis: [
|
||||||
|
path.join(projectRoot, "src", "backend", "database", "routes", "*.ts"),
|
||||||
|
path.join(projectRoot, "src", "backend", "dashboard.ts"),
|
||||||
|
path.join(projectRoot, "src", "backend", "ssh", "*.ts"),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
async function generateOpenAPISpec() {
|
||||||
|
try {
|
||||||
|
const swaggerSpec = swaggerJSDoc(swaggerOptions);
|
||||||
|
|
||||||
|
const outputPath = path.join(projectRoot, "openapi.json");
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
outputPath,
|
||||||
|
JSON.stringify(swaggerSpec, null, 2),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to generate OpenAPI specification:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
generateOpenAPISpec();
|
||||||
|
|
||||||
|
export { swaggerOptions, generateOpenAPISpec };
|
||||||
@@ -32,7 +32,6 @@ class FieldCrypto {
|
|||||||
"key",
|
"key",
|
||||||
"key_password",
|
"key_password",
|
||||||
"keyPassword",
|
"keyPassword",
|
||||||
"keyType",
|
|
||||||
"autostartPassword",
|
"autostartPassword",
|
||||||
"autostartKey",
|
"autostartKey",
|
||||||
"autostartKeyPassword",
|
"autostartKeyPassword",
|
||||||
@@ -46,7 +45,6 @@ class FieldCrypto {
|
|||||||
"key",
|
"key",
|
||||||
"public_key",
|
"public_key",
|
||||||
"publicKey",
|
"publicKey",
|
||||||
"keyType",
|
|
||||||
]),
|
]),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,21 @@ interface LoginAttempt {
|
|||||||
class LoginRateLimiter {
|
class LoginRateLimiter {
|
||||||
private ipAttempts = new Map<string, LoginAttempt>();
|
private ipAttempts = new Map<string, LoginAttempt>();
|
||||||
private usernameAttempts = new Map<string, LoginAttempt>();
|
private usernameAttempts = new Map<string, LoginAttempt>();
|
||||||
|
private totpAttempts = new Map<string, LoginAttempt>();
|
||||||
|
private resetCodeAttempts = new Map<string, LoginAttempt>();
|
||||||
|
|
||||||
private readonly MAX_ATTEMPTS = 5;
|
private readonly MAX_ATTEMPTS = 5;
|
||||||
private readonly WINDOW_MS = 10 * 60 * 1000;
|
private readonly WINDOW_MS = 10 * 60 * 1000;
|
||||||
private readonly LOCKOUT_MS = 10 * 60 * 1000;
|
private readonly LOCKOUT_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
private readonly TOTP_MAX_ATTEMPTS = 5;
|
||||||
|
private readonly TOTP_WINDOW_MS = 1 * 60 * 1000;
|
||||||
|
private readonly TOTP_LOCKOUT_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
private readonly RESET_CODE_MAX_ATTEMPTS = 5;
|
||||||
|
private readonly RESET_CODE_WINDOW_MS = 1 * 60 * 1000;
|
||||||
|
private readonly RESET_CODE_LOCKOUT_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
setInterval(() => this.cleanup(), 5 * 60 * 1000);
|
setInterval(() => this.cleanup(), 5 * 60 * 1000);
|
||||||
}
|
}
|
||||||
@@ -40,6 +50,28 @@ class LoginRateLimiter {
|
|||||||
this.usernameAttempts.delete(username);
|
this.usernameAttempts.delete(username);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const [userId, attempt] of this.totpAttempts.entries()) {
|
||||||
|
if (attempt.lockedUntil && attempt.lockedUntil < now) {
|
||||||
|
this.totpAttempts.delete(userId);
|
||||||
|
} else if (
|
||||||
|
!attempt.lockedUntil &&
|
||||||
|
now - attempt.firstAttempt > this.TOTP_WINDOW_MS
|
||||||
|
) {
|
||||||
|
this.totpAttempts.delete(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [username, attempt] of this.resetCodeAttempts.entries()) {
|
||||||
|
if (attempt.lockedUntil && attempt.lockedUntil < now) {
|
||||||
|
this.resetCodeAttempts.delete(username);
|
||||||
|
} else if (
|
||||||
|
!attempt.lockedUntil &&
|
||||||
|
now - attempt.firstAttempt > this.RESET_CODE_WINDOW_MS
|
||||||
|
) {
|
||||||
|
this.resetCodeAttempts.delete(username);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
recordFailedAttempt(ip: string, username?: string): void {
|
recordFailedAttempt(ip: string, username?: string): void {
|
||||||
@@ -141,6 +173,114 @@ class LoginRateLimiter {
|
|||||||
|
|
||||||
return minRemaining;
|
return minRemaining;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recordFailedTOTPAttempt(userId: string): void {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const totpAttempt = this.totpAttempts.get(userId);
|
||||||
|
if (!totpAttempt) {
|
||||||
|
this.totpAttempts.set(userId, {
|
||||||
|
count: 1,
|
||||||
|
firstAttempt: now,
|
||||||
|
});
|
||||||
|
} else if (now - totpAttempt.firstAttempt > this.TOTP_WINDOW_MS) {
|
||||||
|
this.totpAttempts.set(userId, {
|
||||||
|
count: 1,
|
||||||
|
firstAttempt: now,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
totpAttempt.count++;
|
||||||
|
if (totpAttempt.count >= this.TOTP_MAX_ATTEMPTS) {
|
||||||
|
totpAttempt.lockedUntil = now + this.TOTP_LOCKOUT_MS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resetTOTPAttempts(userId: string): void {
|
||||||
|
this.totpAttempts.delete(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
isTOTPLocked(userId: string): { locked: boolean; remainingTime?: number } {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const totpAttempt = this.totpAttempts.get(userId);
|
||||||
|
if (totpAttempt?.lockedUntil && totpAttempt.lockedUntil > now) {
|
||||||
|
return {
|
||||||
|
locked: true,
|
||||||
|
remainingTime: Math.ceil((totpAttempt.lockedUntil - now) / 1000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { locked: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
getRemainingTOTPAttempts(userId: string): number {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const totpAttempt = this.totpAttempts.get(userId);
|
||||||
|
if (totpAttempt && now - totpAttempt.firstAttempt <= this.TOTP_WINDOW_MS) {
|
||||||
|
return Math.max(0, this.TOTP_MAX_ATTEMPTS - totpAttempt.count);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.TOTP_MAX_ATTEMPTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
recordResetCodeAttempt(username: string): void {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const resetAttempt = this.resetCodeAttempts.get(username);
|
||||||
|
if (!resetAttempt) {
|
||||||
|
this.resetCodeAttempts.set(username, {
|
||||||
|
count: 1,
|
||||||
|
firstAttempt: now,
|
||||||
|
});
|
||||||
|
} else if (now - resetAttempt.firstAttempt > this.RESET_CODE_WINDOW_MS) {
|
||||||
|
this.resetCodeAttempts.set(username, {
|
||||||
|
count: 1,
|
||||||
|
firstAttempt: now,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
resetAttempt.count++;
|
||||||
|
if (resetAttempt.count >= this.RESET_CODE_MAX_ATTEMPTS) {
|
||||||
|
resetAttempt.lockedUntil = now + this.RESET_CODE_LOCKOUT_MS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resetResetCodeAttempts(username: string): void {
|
||||||
|
this.resetCodeAttempts.delete(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
isResetCodeLocked(username: string): {
|
||||||
|
locked: boolean;
|
||||||
|
remainingTime?: number;
|
||||||
|
} {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const resetAttempt = this.resetCodeAttempts.get(username);
|
||||||
|
if (resetAttempt?.lockedUntil && resetAttempt.lockedUntil > now) {
|
||||||
|
return {
|
||||||
|
locked: true,
|
||||||
|
remainingTime: Math.ceil((resetAttempt.lockedUntil - now) / 1000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { locked: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
getRemainingResetCodeAttempts(username: string): number {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const resetAttempt = this.resetCodeAttempts.get(username);
|
||||||
|
if (
|
||||||
|
resetAttempt &&
|
||||||
|
now - resetAttempt.firstAttempt <= this.RESET_CODE_WINDOW_MS
|
||||||
|
) {
|
||||||
|
return Math.max(0, this.RESET_CODE_MAX_ATTEMPTS - resetAttempt.count);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.RESET_CODE_MAX_ATTEMPTS;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loginRateLimiter = new LoginRateLimiter();
|
export const loginRateLimiter = new LoginRateLimiter();
|
||||||
|
|||||||
@@ -177,30 +177,57 @@ class UserDataImport {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tempId = `import-ssh-${targetUserId}-${Date.now()}-${imported}`;
|
const existing = await getDb()
|
||||||
const newHostData = {
|
.select()
|
||||||
|
.from(sshData)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sshData.userId, targetUserId),
|
||||||
|
eq(sshData.ip, host.ip as string),
|
||||||
|
eq(sshData.port, host.port as number),
|
||||||
|
eq(sshData.username, host.username as string),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.length > 0 && !options.replaceExisting) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newHostData: any = {
|
||||||
...host,
|
...host,
|
||||||
id: tempId,
|
|
||||||
userId: targetUserId,
|
userId: targetUserId,
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let processedHostData = newHostData;
|
if (existing.length === 0) {
|
||||||
|
newHostData.createdAt = new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
let processedHostData: any = newHostData;
|
||||||
if (options.userDataKey) {
|
if (options.userDataKey) {
|
||||||
processedHostData = DataCrypto.encryptRecord(
|
processedHostData = DataCrypto.encryptRecord(
|
||||||
"ssh_data",
|
"ssh_data",
|
||||||
newHostData,
|
newHostData,
|
||||||
targetUserId,
|
targetUserId,
|
||||||
options.userDataKey,
|
options.userDataKey,
|
||||||
);
|
) as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
delete processedHostData.id;
|
delete processedHostData.id;
|
||||||
|
|
||||||
await getDb()
|
if (existing.length > 0 && options.replaceExisting) {
|
||||||
.insert(sshData)
|
await getDb()
|
||||||
.values(processedHostData as unknown as typeof sshData.$inferInsert);
|
.update(sshData)
|
||||||
|
.set(processedHostData as unknown as typeof sshData.$inferInsert)
|
||||||
|
.where(eq(sshData.id, existing[0].id));
|
||||||
|
} else {
|
||||||
|
await getDb()
|
||||||
|
.insert(sshData)
|
||||||
|
.values(
|
||||||
|
processedHostData as unknown as typeof sshData.$inferInsert,
|
||||||
|
);
|
||||||
|
}
|
||||||
imported++;
|
imported++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errors.push(
|
errors.push(
|
||||||
@@ -233,34 +260,59 @@ class UserDataImport {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tempCredId = `import-cred-${targetUserId}-${Date.now()}-${imported}`;
|
const existing = await getDb()
|
||||||
const newCredentialData = {
|
.select()
|
||||||
|
.from(sshCredentials)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sshCredentials.userId, targetUserId),
|
||||||
|
eq(sshCredentials.name, credential.name as string),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.length > 0 && !options.replaceExisting) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newCredentialData: any = {
|
||||||
...credential,
|
...credential,
|
||||||
id: tempCredId,
|
|
||||||
userId: targetUserId,
|
userId: targetUserId,
|
||||||
usageCount: 0,
|
|
||||||
lastUsed: null,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let processedCredentialData = newCredentialData;
|
if (existing.length === 0) {
|
||||||
|
newCredentialData.usageCount = 0;
|
||||||
|
newCredentialData.lastUsed = null;
|
||||||
|
newCredentialData.createdAt = new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
let processedCredentialData: any = newCredentialData;
|
||||||
if (options.userDataKey) {
|
if (options.userDataKey) {
|
||||||
processedCredentialData = DataCrypto.encryptRecord(
|
processedCredentialData = DataCrypto.encryptRecord(
|
||||||
"ssh_credentials",
|
"ssh_credentials",
|
||||||
newCredentialData,
|
newCredentialData,
|
||||||
targetUserId,
|
targetUserId,
|
||||||
options.userDataKey,
|
options.userDataKey,
|
||||||
);
|
) as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
delete processedCredentialData.id;
|
delete processedCredentialData.id;
|
||||||
|
|
||||||
await getDb()
|
if (existing.length > 0 && options.replaceExisting) {
|
||||||
.insert(sshCredentials)
|
await getDb()
|
||||||
.values(
|
.update(sshCredentials)
|
||||||
processedCredentialData as unknown as typeof sshCredentials.$inferInsert,
|
.set(
|
||||||
);
|
processedCredentialData as unknown as typeof sshCredentials.$inferInsert,
|
||||||
|
)
|
||||||
|
.where(eq(sshCredentials.id, existing[0].id));
|
||||||
|
} else {
|
||||||
|
await getDb()
|
||||||
|
.insert(sshCredentials)
|
||||||
|
.values(
|
||||||
|
processedCredentialData as unknown as typeof sshCredentials.$inferInsert,
|
||||||
|
);
|
||||||
|
}
|
||||||
imported++;
|
imported++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errors.push(
|
errors.push(
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ function TooltipTrigger({
|
|||||||
|
|
||||||
function TooltipContent({
|
function TooltipContent({
|
||||||
className,
|
className,
|
||||||
sideOffset = 0,
|
sideOffset = 4,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||||
@@ -46,7 +46,7 @@ function TooltipContent({
|
|||||||
data-slot="tooltip-content"
|
data-slot="tooltip-content"
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
"bg-elevated text-foreground border border-edge-medium shadow-lg animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -745,7 +745,7 @@ export const DEFAULT_TERMINAL_CONFIG = {
|
|||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: "Caskaydia Cove Nerd Font Mono",
|
fontFamily: "Caskaydia Cove Nerd Font Mono",
|
||||||
letterSpacing: 0,
|
letterSpacing: 0,
|
||||||
lineHeight: 1.2,
|
lineHeight: 1.0,
|
||||||
theme: "termix",
|
theme: "termix",
|
||||||
|
|
||||||
scrollback: 10000,
|
scrollback: 10000,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface ConfirmationOptions {
|
interface ConfirmationOptions {
|
||||||
@@ -9,10 +9,47 @@ interface ConfirmationOptions {
|
|||||||
variant?: "default" | "destructive";
|
variant?: "default" | "destructive";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ToastConfirmOptions {
|
||||||
|
confirmOnEnter?: boolean;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function useConfirmation() {
|
export function useConfirmation() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [options, setOptions] = useState<ConfirmationOptions | null>(null);
|
const [options, setOptions] = useState<ConfirmationOptions | null>(null);
|
||||||
const [onConfirm, setOnConfirm] = useState<(() => void) | null>(null);
|
const [onConfirm, setOnConfirm] = useState<(() => void) | null>(null);
|
||||||
|
const [activeToastId, setActiveToastId] = useState<string | number | null>(null);
|
||||||
|
const [pendingConfirmCallback, setPendingConfirmCallback] = useState<(() => void) | null>(null);
|
||||||
|
const [pendingResolve, setPendingResolve] = useState<((value: boolean) => void) | null>(null);
|
||||||
|
|
||||||
|
const handleEnterKey = useCallback((event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Enter" && activeToastId !== null) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
if (pendingConfirmCallback) {
|
||||||
|
pendingConfirmCallback();
|
||||||
|
}
|
||||||
|
if (pendingResolve) {
|
||||||
|
pendingResolve(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.dismiss(activeToastId);
|
||||||
|
setActiveToastId(null);
|
||||||
|
setPendingConfirmCallback(null);
|
||||||
|
setPendingResolve(null);
|
||||||
|
}
|
||||||
|
}, [activeToastId, pendingConfirmCallback, pendingResolve]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeToastId !== null) {
|
||||||
|
// Use capture phase to intercept Enter before terminal receives it
|
||||||
|
window.addEventListener("keydown", handleEnterKey, true);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleEnterKey, true);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [activeToastId, handleEnterKey]);
|
||||||
|
|
||||||
const confirm = (opts: ConfirmationOptions, callback: () => void) => {
|
const confirm = (opts: ConfirmationOptions, callback: () => void) => {
|
||||||
setOptions(opts);
|
setOptions(opts);
|
||||||
@@ -40,6 +77,7 @@ export function useConfirmation() {
|
|||||||
callback?: () => void,
|
callback?: () => void,
|
||||||
variantOrConfirmLabel: "default" | "destructive" | string = "Confirm",
|
variantOrConfirmLabel: "default" | "destructive" | string = "Confirm",
|
||||||
cancelLabel: string = "Cancel",
|
cancelLabel: string = "Cancel",
|
||||||
|
toastOptions: ToastConfirmOptions = { confirmOnEnter: false },
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const isVariant =
|
const isVariant =
|
||||||
@@ -47,43 +85,56 @@ export function useConfirmation() {
|
|||||||
variantOrConfirmLabel === "destructive";
|
variantOrConfirmLabel === "destructive";
|
||||||
const confirmLabel = isVariant ? "Confirm" : variantOrConfirmLabel;
|
const confirmLabel = isVariant ? "Confirm" : variantOrConfirmLabel;
|
||||||
|
|
||||||
if (typeof opts === "string") {
|
const { confirmOnEnter = false, duration = 8000 } = toastOptions;
|
||||||
toast(opts, {
|
|
||||||
action: {
|
|
||||||
label: confirmLabel,
|
|
||||||
onClick: () => {
|
|
||||||
if (callback) callback();
|
|
||||||
resolve(true);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
cancel: {
|
|
||||||
label: cancelLabel,
|
|
||||||
onClick: () => {
|
|
||||||
resolve(false);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as any);
|
|
||||||
} else if (typeof opts === "object") {
|
|
||||||
const actualConfirmLabel = opts.confirmText || confirmLabel;
|
|
||||||
const actualCancelLabel = opts.cancelText || cancelLabel;
|
|
||||||
|
|
||||||
toast(opts.description, {
|
const handleToastConfirm = () => {
|
||||||
action: {
|
if (callback) callback();
|
||||||
label: actualConfirmLabel,
|
resolve(true);
|
||||||
onClick: () => {
|
setActiveToastId(null);
|
||||||
if (callback) callback();
|
setPendingConfirmCallback(null);
|
||||||
resolve(true);
|
setPendingResolve(null);
|
||||||
},
|
};
|
||||||
},
|
|
||||||
cancel: {
|
const handleToastCancel = () => {
|
||||||
label: actualCancelLabel,
|
|
||||||
onClick: () => {
|
|
||||||
resolve(false);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as any);
|
|
||||||
} else {
|
|
||||||
resolve(false);
|
resolve(false);
|
||||||
|
setActiveToastId(null);
|
||||||
|
setPendingConfirmCallback(null);
|
||||||
|
setPendingResolve(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const message = typeof opts === "string" ? opts : opts.description;
|
||||||
|
const actualConfirmLabel = typeof opts === "object" && opts.confirmText ? opts.confirmText : confirmLabel;
|
||||||
|
const actualCancelLabel = typeof opts === "object" && opts.cancelText ? opts.cancelText : cancelLabel;
|
||||||
|
|
||||||
|
const toastId = toast(message, {
|
||||||
|
duration,
|
||||||
|
action: {
|
||||||
|
label: confirmOnEnter ? `${actualConfirmLabel} ↵` : actualConfirmLabel,
|
||||||
|
onClick: handleToastConfirm,
|
||||||
|
},
|
||||||
|
cancel: {
|
||||||
|
label: actualCancelLabel,
|
||||||
|
onClick: handleToastCancel,
|
||||||
|
},
|
||||||
|
onDismiss: () => {
|
||||||
|
setActiveToastId(null);
|
||||||
|
setPendingConfirmCallback(null);
|
||||||
|
setPendingResolve(null);
|
||||||
|
},
|
||||||
|
onAutoClose: () => {
|
||||||
|
resolve(false);
|
||||||
|
setActiveToastId(null);
|
||||||
|
setPendingConfirmCallback(null);
|
||||||
|
setPendingResolve(null);
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
if (confirmOnEnter) {
|
||||||
|
setActiveToastId(toastId);
|
||||||
|
setPendingConfirmCallback(() => () => {
|
||||||
|
if (callback) callback();
|
||||||
|
});
|
||||||
|
setPendingResolve(() => resolve);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
71
src/hooks/use-service-worker.ts
Normal file
71
src/hooks/use-service-worker.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { isElectron } from "@/ui/main-axios";
|
||||||
|
|
||||||
|
interface ServiceWorkerState {
|
||||||
|
isSupported: boolean;
|
||||||
|
isRegistered: boolean;
|
||||||
|
updateAvailable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to manage PWA Service Worker registration.
|
||||||
|
* Only registers in production web environment (not in Electron).
|
||||||
|
*/
|
||||||
|
export function useServiceWorker(): ServiceWorkerState {
|
||||||
|
const [state, setState] = useState<ServiceWorkerState>({
|
||||||
|
isSupported: false,
|
||||||
|
isRegistered: false,
|
||||||
|
updateAvailable: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleUpdateFound = useCallback(
|
||||||
|
(registration: ServiceWorkerRegistration) => {
|
||||||
|
const newWorker = registration.installing;
|
||||||
|
if (!newWorker) return;
|
||||||
|
|
||||||
|
newWorker.addEventListener("statechange", () => {
|
||||||
|
if (
|
||||||
|
newWorker.state === "installed" &&
|
||||||
|
navigator.serviceWorker.controller
|
||||||
|
) {
|
||||||
|
setState((prev) => ({ ...prev, updateAvailable: true }));
|
||||||
|
console.log("[SW] Update available");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const isSupported =
|
||||||
|
"serviceWorker" in navigator && !isElectron() && import.meta.env.PROD;
|
||||||
|
|
||||||
|
setState((prev) => ({ ...prev, isSupported }));
|
||||||
|
|
||||||
|
if (!isSupported) return;
|
||||||
|
|
||||||
|
const registerSW = async () => {
|
||||||
|
try {
|
||||||
|
const registration = await navigator.serviceWorker.register("/sw.js");
|
||||||
|
console.log("[SW] Registered:", registration.scope);
|
||||||
|
|
||||||
|
setState((prev) => ({ ...prev, isRegistered: true }));
|
||||||
|
|
||||||
|
registration.addEventListener("updatefound", () =>
|
||||||
|
handleUpdateFound(registration),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[SW] Registration failed:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (document.readyState === "complete") {
|
||||||
|
registerSW();
|
||||||
|
} else {
|
||||||
|
window.addEventListener("load", registerSW);
|
||||||
|
return () => window.removeEventListener("load", registerSW);
|
||||||
|
}
|
||||||
|
}, [handleUpdateFound]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
249
src/i18n/i18n.ts
249
src/i18n/i18n.ts
@@ -3,31 +3,38 @@ import { initReactI18next } from "react-i18next";
|
|||||||
import LanguageDetector from "i18next-browser-languagedetector";
|
import LanguageDetector from "i18next-browser-languagedetector";
|
||||||
|
|
||||||
import enTranslation from "../locales/en.json";
|
import enTranslation from "../locales/en.json";
|
||||||
import zhTranslation from "../locales/zh.json";
|
import afTranslation from "../locales/translated/af.json";
|
||||||
import deTranslation from "../locales/de.json";
|
import arTranslation from "../locales/translated/ar.json";
|
||||||
import ptTranslation from "../locales/pt.json";
|
import bnTranslation from "../locales/translated/bn.json";
|
||||||
import ruTranslation from "../locales/ru.json";
|
import bgTranslation from "../locales/translated/bg.json";
|
||||||
import frTranslation from "../locales/fr.json";
|
import caTranslation from "../locales/translated/ca.json";
|
||||||
import koTranslation from "../locales/ko.json";
|
import csTranslation from "../locales/translated/cs.json";
|
||||||
import itTranslation from "../locales/it.json";
|
import daTranslation from "../locales/translated/da.json";
|
||||||
import esTranslation from "../locales/es.json";
|
import deTranslation from "../locales/translated/de.json";
|
||||||
import hiTranslation from "../locales/hi.json";
|
import elTranslation from "../locales/translated/el.json";
|
||||||
import bnTranslation from "../locales/bn.json";
|
import esTranslation from "../locales/translated/es.json";
|
||||||
import jaTranslation from "../locales/ja.json";
|
import fiTranslation from "../locales/translated/fi.json";
|
||||||
import viTranslation from "../locales/vi.json";
|
import frTranslation from "../locales/translated/fr.json";
|
||||||
import trTranslation from "../locales/tr.json";
|
import heTranslation from "../locales/translated/he.json";
|
||||||
import heTranslation from "../locales/he.json";
|
import hiTranslation from "../locales/translated/hi.json";
|
||||||
import arTranslation from "../locales/ar.json";
|
import huTranslation from "../locales/translated/hu.json";
|
||||||
import plTranslation from "../locales/pl.json";
|
import idTranslation from "../locales/translated/id.json";
|
||||||
import nlTranslation from "../locales/nl.json";
|
import itTranslation from "../locales/translated/it.json";
|
||||||
import svTranslation from "../locales/sv.json";
|
import jaTranslation from "../locales/translated/ja.json";
|
||||||
import idTranslation from "../locales/id.json";
|
import koTranslation from "../locales/translated/ko.json";
|
||||||
import thTranslation from "../locales/th.json";
|
import nlTranslation from "../locales/translated/nl.json";
|
||||||
import ukTranslation from "../locales/uk.json";
|
import noTranslation from "../locales/translated/no.json";
|
||||||
import csTranslation from "../locales/cs.json";
|
import plTranslation from "../locales/translated/pl.json";
|
||||||
import roTranslation from "../locales/ro.json";
|
import ptTranslation from "../locales/translated/pt.json";
|
||||||
import elTranslation from "../locales/el.json";
|
import roTranslation from "../locales/translated/ro.json";
|
||||||
import nbTranslation from "../locales/nb.json";
|
import ruTranslation from "../locales/translated/ru.json";
|
||||||
|
import srTranslation from "../locales/translated/sr.json";
|
||||||
|
import svTranslation from "../locales/translated/sv.json";
|
||||||
|
import thTranslation from "../locales/translated/th.json";
|
||||||
|
import trTranslation from "../locales/translated/tr.json";
|
||||||
|
import ukTranslation from "../locales/translated/uk.json";
|
||||||
|
import viTranslation from "../locales/translated/vi.json";
|
||||||
|
import zhTranslation from "../locales/translated/zh.json";
|
||||||
|
|
||||||
i18n
|
i18n
|
||||||
.use(LanguageDetector)
|
.use(LanguageDetector)
|
||||||
@@ -35,31 +42,38 @@ i18n
|
|||||||
.init({
|
.init({
|
||||||
supportedLngs: [
|
supportedLngs: [
|
||||||
"en",
|
"en",
|
||||||
"zh",
|
"af",
|
||||||
"de",
|
|
||||||
"pt",
|
|
||||||
"ru",
|
|
||||||
"fr",
|
|
||||||
"ko",
|
|
||||||
"it",
|
|
||||||
"es",
|
|
||||||
"hi",
|
|
||||||
"bn",
|
|
||||||
"ja",
|
|
||||||
"vi",
|
|
||||||
"tr",
|
|
||||||
"he",
|
|
||||||
"ar",
|
"ar",
|
||||||
"pl",
|
"bn",
|
||||||
"nl",
|
"bg",
|
||||||
"sv",
|
"ca",
|
||||||
"id",
|
|
||||||
"th",
|
|
||||||
"uk",
|
|
||||||
"cs",
|
"cs",
|
||||||
"ro",
|
"da",
|
||||||
|
"de",
|
||||||
"el",
|
"el",
|
||||||
"nb",
|
"es",
|
||||||
|
"fi",
|
||||||
|
"fr",
|
||||||
|
"he",
|
||||||
|
"hi",
|
||||||
|
"hu",
|
||||||
|
"id",
|
||||||
|
"it",
|
||||||
|
"ja",
|
||||||
|
"ko",
|
||||||
|
"nl",
|
||||||
|
"no",
|
||||||
|
"pl",
|
||||||
|
"pt",
|
||||||
|
"ro",
|
||||||
|
"ru",
|
||||||
|
"sr",
|
||||||
|
"sv",
|
||||||
|
"th",
|
||||||
|
"tr",
|
||||||
|
"uk",
|
||||||
|
"vi",
|
||||||
|
"zh",
|
||||||
],
|
],
|
||||||
fallbackLng: "en",
|
fallbackLng: "en",
|
||||||
debug: false,
|
debug: false,
|
||||||
@@ -76,80 +90,101 @@ i18n
|
|||||||
en: {
|
en: {
|
||||||
translation: enTranslation,
|
translation: enTranslation,
|
||||||
},
|
},
|
||||||
zh: {
|
af: {
|
||||||
translation: zhTranslation,
|
translation: afTranslation,
|
||||||
},
|
|
||||||
de: {
|
|
||||||
translation: deTranslation,
|
|
||||||
},
|
|
||||||
pt: {
|
|
||||||
translation: ptTranslation,
|
|
||||||
},
|
|
||||||
ru: {
|
|
||||||
translation: ruTranslation,
|
|
||||||
},
|
|
||||||
fr: {
|
|
||||||
translation: frTranslation,
|
|
||||||
},
|
|
||||||
ko: {
|
|
||||||
translation: koTranslation,
|
|
||||||
},
|
|
||||||
it: {
|
|
||||||
translation: itTranslation,
|
|
||||||
},
|
|
||||||
es: {
|
|
||||||
translation: esTranslation,
|
|
||||||
},
|
|
||||||
hi: {
|
|
||||||
translation: hiTranslation,
|
|
||||||
},
|
|
||||||
bn: {
|
|
||||||
translation: bnTranslation,
|
|
||||||
},
|
|
||||||
ja: {
|
|
||||||
translation: jaTranslation,
|
|
||||||
},
|
|
||||||
vi: {
|
|
||||||
translation: viTranslation,
|
|
||||||
},
|
|
||||||
tr: {
|
|
||||||
translation: trTranslation,
|
|
||||||
},
|
|
||||||
he: {
|
|
||||||
translation: heTranslation,
|
|
||||||
},
|
},
|
||||||
ar: {
|
ar: {
|
||||||
translation: arTranslation,
|
translation: arTranslation,
|
||||||
},
|
},
|
||||||
pl: {
|
bn: {
|
||||||
translation: plTranslation,
|
translation: bnTranslation,
|
||||||
},
|
},
|
||||||
nl: {
|
bg: {
|
||||||
translation: nlTranslation,
|
translation: bgTranslation,
|
||||||
},
|
},
|
||||||
sv: {
|
ca: {
|
||||||
translation: svTranslation,
|
translation: caTranslation,
|
||||||
},
|
|
||||||
id: {
|
|
||||||
translation: idTranslation,
|
|
||||||
},
|
|
||||||
th: {
|
|
||||||
translation: thTranslation,
|
|
||||||
},
|
|
||||||
uk: {
|
|
||||||
translation: ukTranslation,
|
|
||||||
},
|
},
|
||||||
cs: {
|
cs: {
|
||||||
translation: csTranslation,
|
translation: csTranslation,
|
||||||
},
|
},
|
||||||
ro: {
|
da: {
|
||||||
translation: roTranslation,
|
translation: daTranslation,
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
translation: deTranslation,
|
||||||
},
|
},
|
||||||
el: {
|
el: {
|
||||||
translation: elTranslation,
|
translation: elTranslation,
|
||||||
},
|
},
|
||||||
nb: {
|
es: {
|
||||||
translation: nbTranslation,
|
translation: esTranslation,
|
||||||
|
},
|
||||||
|
fi: {
|
||||||
|
translation: fiTranslation,
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
translation: frTranslation,
|
||||||
|
},
|
||||||
|
he: {
|
||||||
|
translation: heTranslation,
|
||||||
|
},
|
||||||
|
hi: {
|
||||||
|
translation: hiTranslation,
|
||||||
|
},
|
||||||
|
hu: {
|
||||||
|
translation: huTranslation,
|
||||||
|
},
|
||||||
|
id: {
|
||||||
|
translation: idTranslation,
|
||||||
|
},
|
||||||
|
it: {
|
||||||
|
translation: itTranslation,
|
||||||
|
},
|
||||||
|
ja: {
|
||||||
|
translation: jaTranslation,
|
||||||
|
},
|
||||||
|
ko: {
|
||||||
|
translation: koTranslation,
|
||||||
|
},
|
||||||
|
nl: {
|
||||||
|
translation: nlTranslation,
|
||||||
|
},
|
||||||
|
no: {
|
||||||
|
translation: noTranslation,
|
||||||
|
},
|
||||||
|
pl: {
|
||||||
|
translation: plTranslation,
|
||||||
|
},
|
||||||
|
pt: {
|
||||||
|
translation: ptTranslation,
|
||||||
|
},
|
||||||
|
ro: {
|
||||||
|
translation: roTranslation,
|
||||||
|
},
|
||||||
|
ru: {
|
||||||
|
translation: ruTranslation,
|
||||||
|
},
|
||||||
|
sr: {
|
||||||
|
translation: srTranslation,
|
||||||
|
},
|
||||||
|
sv: {
|
||||||
|
translation: svTranslation,
|
||||||
|
},
|
||||||
|
th: {
|
||||||
|
translation: thTranslation,
|
||||||
|
},
|
||||||
|
tr: {
|
||||||
|
translation: trTranslation,
|
||||||
|
},
|
||||||
|
uk: {
|
||||||
|
translation: ukTranslation,
|
||||||
|
},
|
||||||
|
vi: {
|
||||||
|
translation: viTranslation,
|
||||||
|
},
|
||||||
|
zh: {
|
||||||
|
translation: zhTranslation,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
104
src/lib/db-health-monitor.ts
Normal file
104
src/lib/db-health-monitor.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
type EventListener = (...args: any[]) => void;
|
||||||
|
|
||||||
|
class DatabaseHealthMonitor {
|
||||||
|
private static instance: DatabaseHealthMonitor;
|
||||||
|
private dbHealthy: boolean = true;
|
||||||
|
private lastCheckTime: number = 0;
|
||||||
|
private checkInProgress: boolean = false;
|
||||||
|
private listeners: Map<string, EventListener[]> = new Map();
|
||||||
|
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
static getInstance(): DatabaseHealthMonitor {
|
||||||
|
if (!DatabaseHealthMonitor.instance) {
|
||||||
|
DatabaseHealthMonitor.instance = new DatabaseHealthMonitor();
|
||||||
|
}
|
||||||
|
return DatabaseHealthMonitor.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
on(event: string, listener: EventListener): void {
|
||||||
|
if (!this.listeners.has(event)) {
|
||||||
|
this.listeners.set(event, []);
|
||||||
|
}
|
||||||
|
this.listeners.get(event)!.push(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
off(event: string, listener: EventListener): void {
|
||||||
|
const eventListeners = this.listeners.get(event);
|
||||||
|
if (eventListeners) {
|
||||||
|
const index = eventListeners.indexOf(listener);
|
||||||
|
if (index !== -1) {
|
||||||
|
eventListeners.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(event: string, ...args: any[]): void {
|
||||||
|
const eventListeners = this.listeners.get(event);
|
||||||
|
if (eventListeners) {
|
||||||
|
eventListeners.forEach((listener) => listener(...args));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reportDatabaseError(error: any, wasAuthenticated: boolean = false) {
|
||||||
|
const errorMessage = error?.response?.data?.error || error?.message || "";
|
||||||
|
const errorCode = error?.response?.data?.code || error?.code;
|
||||||
|
const httpStatus = error?.response?.status;
|
||||||
|
|
||||||
|
const isDatabaseError =
|
||||||
|
errorMessage.toLowerCase().includes("database") ||
|
||||||
|
errorMessage.toLowerCase().includes("sqlite") ||
|
||||||
|
errorMessage.toLowerCase().includes("drizzle") ||
|
||||||
|
errorCode === "DATABASE_ERROR" ||
|
||||||
|
errorCode === "DB_CONNECTION_FAILED";
|
||||||
|
|
||||||
|
const isBackendUnreachable =
|
||||||
|
errorCode === "ERR_NETWORK" ||
|
||||||
|
errorCode === "ECONNREFUSED" ||
|
||||||
|
(errorMessage.toLowerCase().includes("network error") &&
|
||||||
|
error?.response === undefined);
|
||||||
|
|
||||||
|
const isAuthenticationLost =
|
||||||
|
wasAuthenticated &&
|
||||||
|
httpStatus === 401 &&
|
||||||
|
(errorCode === "AUTH_REQUIRED" ||
|
||||||
|
errorCode === "SESSION_EXPIRED" ||
|
||||||
|
errorCode === "SESSION_NOT_FOUND" ||
|
||||||
|
errorMessage === "Missing authentication token" ||
|
||||||
|
errorMessage === "Invalid token" ||
|
||||||
|
errorMessage === "Authentication required");
|
||||||
|
|
||||||
|
if (
|
||||||
|
(isDatabaseError || isBackendUnreachable || isAuthenticationLost) &&
|
||||||
|
this.dbHealthy
|
||||||
|
) {
|
||||||
|
this.dbHealthy = false;
|
||||||
|
this.emit("database-connection-lost", {
|
||||||
|
error: errorMessage || "Backend server unreachable",
|
||||||
|
code: errorCode,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reportDatabaseSuccess() {
|
||||||
|
if (!this.dbHealthy) {
|
||||||
|
this.dbHealthy = true;
|
||||||
|
this.emit("database-connection-restored", {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isDatabaseHealthy(): boolean {
|
||||||
|
return this.dbHealthy;
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.dbHealthy = true;
|
||||||
|
this.lastCheckTime = 0;
|
||||||
|
this.checkInProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dbHealthMonitor = DatabaseHealthMonitor.getInstance();
|
||||||
2402
src/locales/ar.json
2402
src/locales/ar.json
File diff suppressed because it is too large
Load Diff
2402
src/locales/bn.json
2402
src/locales/bn.json
File diff suppressed because it is too large
Load Diff
2402
src/locales/cs.json
2402
src/locales/cs.json
File diff suppressed because it is too large
Load Diff
2402
src/locales/de.json
2402
src/locales/de.json
File diff suppressed because it is too large
Load Diff
2402
src/locales/el.json
2402
src/locales/el.json
File diff suppressed because it is too large
Load Diff
@@ -44,6 +44,8 @@
|
|||||||
"passwordRequired": "Password is required",
|
"passwordRequired": "Password is required",
|
||||||
"sshKeyRequired": "SSH key is required",
|
"sshKeyRequired": "SSH key is required",
|
||||||
"credentialAddedSuccessfully": "Credential \"{{name}}\" added successfully",
|
"credentialAddedSuccessfully": "Credential \"{{name}}\" added successfully",
|
||||||
|
"savingCredential": "Saving credential...",
|
||||||
|
"updatingCredential": "Updating credential...",
|
||||||
"general": "General",
|
"general": "General",
|
||||||
"description": "Description",
|
"description": "Description",
|
||||||
"folder": "Folder",
|
"folder": "Folder",
|
||||||
@@ -184,6 +186,32 @@
|
|||||||
"renameFolder": "Rename folder",
|
"renameFolder": "Rename folder",
|
||||||
"idLabel": "ID:"
|
"idLabel": "ID:"
|
||||||
},
|
},
|
||||||
|
"quickConnect": {
|
||||||
|
"title": "Quick Connect",
|
||||||
|
"description": "Connect directly to a terminal or file manager session without saving a host configuration",
|
||||||
|
"ipAddress": "IP Address or Hostname",
|
||||||
|
"port": "Port",
|
||||||
|
"username": "Username",
|
||||||
|
"password": "Password",
|
||||||
|
"key": "SSH Private Key",
|
||||||
|
"keyPassword": "Key Password (Optional)",
|
||||||
|
"keyType": "Key Type",
|
||||||
|
"uploadFile": "Upload File",
|
||||||
|
"pasteKey": "Paste Key",
|
||||||
|
"credential": "Credential",
|
||||||
|
"overrideUsername": "Override Credential Username",
|
||||||
|
"overrideUsernameDesc": "Use a different username than the one stored in the credential",
|
||||||
|
"connectTerminal": "Connect to Terminal",
|
||||||
|
"connectFileManager": "Connect to File Manager",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"passwordRequired": "Password is required",
|
||||||
|
"keyRequired": "SSH key is required",
|
||||||
|
"credentialRequired": "Credential selection is required",
|
||||||
|
"connectionEstablished": "Connection established successfully",
|
||||||
|
"connectionFailed": "Failed to establish connection",
|
||||||
|
"autoDetect": "Auto Detect",
|
||||||
|
"authentication": "Authentication"
|
||||||
|
},
|
||||||
"dragIndicator": {
|
"dragIndicator": {
|
||||||
"error": "Error: {{error}}",
|
"error": "Error: {{error}}",
|
||||||
"dragging": "Dragging {{fileName}}",
|
"dragging": "Dragging {{fileName}}",
|
||||||
@@ -464,6 +492,7 @@
|
|||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"checking": "Checking...",
|
"checking": "Checking...",
|
||||||
"checkingDatabase": "Checking database connection...",
|
"checkingDatabase": "Checking database connection...",
|
||||||
|
"checkingAuthentication": "Checking authentication...",
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"revoke": "Revoke",
|
"revoke": "Revoke",
|
||||||
@@ -489,7 +518,12 @@
|
|||||||
"hostManager": "Host Manager",
|
"hostManager": "Host Manager",
|
||||||
"cannotSplitTab": "Cannot split this tab",
|
"cannotSplitTab": "Cannot split this tab",
|
||||||
"tabNavigation": "Tab Navigation",
|
"tabNavigation": "Tab Navigation",
|
||||||
"hostTabTitle": "{{username}}@{{ip}}:{{port}}"
|
"hostTabTitle": "{{username}}@{{ip}}:{{port}}",
|
||||||
|
"copyPassword": "Copy Password",
|
||||||
|
"copySudoPassword": "Copy Sudo Password",
|
||||||
|
"passwordCopied": "Password copied to clipboard",
|
||||||
|
"sudoPasswordCopied": "Sudo password copied to clipboard",
|
||||||
|
"noPasswordAvailable": "No password available"
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"title": "Admin Settings",
|
"title": "Admin Settings",
|
||||||
@@ -537,6 +571,7 @@
|
|||||||
"userRegistration": "User Registration",
|
"userRegistration": "User Registration",
|
||||||
"allowNewAccountRegistration": "Allow new account registration",
|
"allowNewAccountRegistration": "Allow new account registration",
|
||||||
"allowPasswordLogin": "Allow username/password login",
|
"allowPasswordLogin": "Allow username/password login",
|
||||||
|
"allowPasswordReset": "Allow password reset via reset code",
|
||||||
"missingRequiredFields": "Missing required fields: {{fields}}",
|
"missingRequiredFields": "Missing required fields: {{fields}}",
|
||||||
"oidcConfigurationUpdated": "OIDC configuration updated successfully!",
|
"oidcConfigurationUpdated": "OIDC configuration updated successfully!",
|
||||||
"failedToFetchOidcConfig": "Failed to fetch OIDC configuration",
|
"failedToFetchOidcConfig": "Failed to fetch OIDC configuration",
|
||||||
@@ -856,6 +891,13 @@
|
|||||||
"autoStartContainer": "Auto Start on Container Launch",
|
"autoStartContainer": "Auto Start on Container Launch",
|
||||||
"autoStartDesc": "Automatically start this tunnel when the container launches",
|
"autoStartDesc": "Automatically start this tunnel when the container launches",
|
||||||
"addConnection": "Add Tunnel Connection",
|
"addConnection": "Add Tunnel Connection",
|
||||||
|
"tunnelType": "Tunnel Type",
|
||||||
|
"tunnelTypeLocal": "Local (-L)",
|
||||||
|
"tunnelTypeRemote": "Remote (-R)",
|
||||||
|
"tunnelTypeLocalDesc": "Forward local port to remote endpoint",
|
||||||
|
"tunnelTypeRemoteDesc": "Forward remote port to local machine",
|
||||||
|
"tunnelForwardDescriptionLocal": "This tunnel will forward traffic from local port {{sourcePort}} to port {{endpointPort}} on the endpoint machine.",
|
||||||
|
"tunnelForwardDescriptionRemote": "This tunnel will forward traffic from port {{sourcePort}} on the source machine (current connection details in general tab) to port {{endpointPort}} on the endpoint machine.",
|
||||||
"sshpassRequired": "Sshpass Required For Password Authentication",
|
"sshpassRequired": "Sshpass Required For Password Authentication",
|
||||||
"sshpassRequiredDesc": "For password authentication in tunnels, sshpass must be installed on the system.",
|
"sshpassRequiredDesc": "For password authentication in tunnels, sshpass must be installed on the system.",
|
||||||
"otherInstallMethods": "Other installation methods:",
|
"otherInstallMethods": "Other installation methods:",
|
||||||
@@ -1106,6 +1148,19 @@
|
|||||||
"quickActionName": "Action name",
|
"quickActionName": "Action name",
|
||||||
"noSnippetFound": "No snippet found",
|
"noSnippetFound": "No snippet found",
|
||||||
"quickActionsOrder": "Quick action buttons will appear in the order listed above on the Server Stats page",
|
"quickActionsOrder": "Quick action buttons will appear in the order listed above on the Server Stats page",
|
||||||
|
"sidebarCustomization": "Sidebar Button Customization",
|
||||||
|
"sidebarCustomizationDesc": "Choose which actions appear as quick buttons in the sidebar. Actions not shown as buttons will appear in the dropdown menu.",
|
||||||
|
"showTerminalInSidebar": "Show Terminal Button",
|
||||||
|
"showTerminalInSidebarDesc": "Display terminal as a quick button in the sidebar",
|
||||||
|
"showFileManagerInSidebar": "Show File Manager Button",
|
||||||
|
"showFileManagerInSidebarDesc": "Display file manager as a quick button in the sidebar",
|
||||||
|
"showTunnelInSidebar": "Show Tunnel Button",
|
||||||
|
"showTunnelInSidebarDesc": "Display tunnel management as a quick button in the sidebar",
|
||||||
|
"showDockerInSidebar": "Show Docker Button",
|
||||||
|
"showDockerInSidebarDesc": "Display docker management as a quick button in the sidebar",
|
||||||
|
"showServerStatsInSidebar": "Show Server Stats Button",
|
||||||
|
"showServerStatsInSidebarDesc": "Display server statistics as a quick button in the sidebar",
|
||||||
|
"atLeastOneActionRequired": "At least one enabled action must be shown in the sidebar",
|
||||||
"advancedAuthSettings": "Advanced Authentication Settings",
|
"advancedAuthSettings": "Advanced Authentication Settings",
|
||||||
"sudoPasswordAutoFill": "Sudo Password Auto-Fill",
|
"sudoPasswordAutoFill": "Sudo Password Auto-Fill",
|
||||||
"sudoPasswordAutoFillDesc": "Automatically offer to insert SSH password when sudo prompts for password",
|
"sudoPasswordAutoFillDesc": "Automatically offer to insert SSH password when sudo prompts for password",
|
||||||
@@ -1360,6 +1415,12 @@
|
|||||||
"itemDeletedSuccessfully": "{{type}} deleted successfully",
|
"itemDeletedSuccessfully": "{{type}} deleted successfully",
|
||||||
"itemsDeletedSuccessfully": "{{count}} items deleted successfully",
|
"itemsDeletedSuccessfully": "{{count}} items deleted successfully",
|
||||||
"failedToDeleteItems": "Failed to delete items",
|
"failedToDeleteItems": "Failed to delete items",
|
||||||
|
"sudoPasswordRequired": "Administrator Password Required",
|
||||||
|
"enterSudoPassword": "Enter sudo password to continue this operation",
|
||||||
|
"sudoPassword": "Sudo password",
|
||||||
|
"sudoOperationFailed": "Sudo operation failed",
|
||||||
|
"sudoAuthFailed": "Sudo authentication failed",
|
||||||
|
"deleteOperation": "Delete files/folders",
|
||||||
"dragFilesToUpload": "Drop files here to upload",
|
"dragFilesToUpload": "Drop files here to upload",
|
||||||
"emptyFolder": "This folder is empty",
|
"emptyFolder": "This folder is empty",
|
||||||
"itemCount": "{{count}} items",
|
"itemCount": "{{count}} items",
|
||||||
@@ -1731,7 +1792,34 @@
|
|||||||
"executingQuickAction": "Executing {{name}}...",
|
"executingQuickAction": "Executing {{name}}...",
|
||||||
"quickActionSuccess": "{{name}} completed successfully",
|
"quickActionSuccess": "{{name}} completed successfully",
|
||||||
"quickActionFailed": "{{name}} failed",
|
"quickActionFailed": "{{name}} failed",
|
||||||
"quickActionError": "Failed to execute {{name}}"
|
"quickActionError": "Failed to execute {{name}}",
|
||||||
|
"ports": {
|
||||||
|
"title": "Listening Ports",
|
||||||
|
"protocol": "Protocol",
|
||||||
|
"port": "Port",
|
||||||
|
"address": "Address",
|
||||||
|
"state": "State",
|
||||||
|
"process": "Process",
|
||||||
|
"noData": "No listening ports data"
|
||||||
|
},
|
||||||
|
"firewall": {
|
||||||
|
"title": "Firewall",
|
||||||
|
"active": "Active",
|
||||||
|
"inactive": "Inactive",
|
||||||
|
"notDetected": "Not Detected",
|
||||||
|
"policy": "Policy",
|
||||||
|
"rules": "rules",
|
||||||
|
"noRules": "No rules",
|
||||||
|
"noData": "No firewall data available",
|
||||||
|
"action": "Action",
|
||||||
|
"protocol": "Proto",
|
||||||
|
"port": "Port",
|
||||||
|
"source": "Source",
|
||||||
|
"accept": "ACCEPT",
|
||||||
|
"drop": "DROP",
|
||||||
|
"reject": "REJECT",
|
||||||
|
"anywhere": "Anywhere"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"tagline": "SSH SERVER MANAGER",
|
"tagline": "SSH SERVER MANAGER",
|
||||||
@@ -1841,7 +1929,8 @@
|
|||||||
"authenticationDisabled": "Authentication Disabled",
|
"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",
|
"passwordResetSuccess": "Password Reset Successful",
|
||||||
"passwordResetSuccessDesc": "Your password has been reset successfully. You can now log in with your new password."
|
"passwordResetSuccessDesc": "Your password has been reset successfully. You can now log in with your new password.",
|
||||||
|
"attemptsRemaining": "attempts remaining"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"notFound": "Page not found",
|
"notFound": "Page not found",
|
||||||
@@ -1873,7 +1962,11 @@
|
|||||||
"emailExists": "Email already exists",
|
"emailExists": "Email already exists",
|
||||||
"loadFailed": "Failed to load data",
|
"loadFailed": "Failed to load data",
|
||||||
"saveError": "Failed to save",
|
"saveError": "Failed to save",
|
||||||
"sessionExpired": "Session expired - please log in again"
|
"sessionExpired": "Session expired - please log in again",
|
||||||
|
"totpRateLimited": "Rate limited: Too many TOTP verification attempts. Please try again later.",
|
||||||
|
"totpRateLimitedWithTime": "Rate limited: Too many TOTP verification attempts. Please wait {{time}} seconds before trying again.",
|
||||||
|
"resetCodeRateLimited": "Rate limited: Too many verification attempts. Please try again later.",
|
||||||
|
"resetCodeRateLimitedWithTime": "Rate limited: Too many verification attempts. Please wait {{time}} seconds before trying again."
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"saveSuccess": "Saved successfully",
|
"saveSuccess": "Saved successfully",
|
||||||
@@ -1931,6 +2024,9 @@
|
|||||||
"terminalSettings": "Terminal",
|
"terminalSettings": "Terminal",
|
||||||
"hostSidebarSettings": "Host & Sidebar",
|
"hostSidebarSettings": "Host & Sidebar",
|
||||||
"snippetsSettings": "Snippets",
|
"snippetsSettings": "Snippets",
|
||||||
|
"updateSettings": "Updates",
|
||||||
|
"disableUpdateCheck": "Disable Update Check",
|
||||||
|
"disableUpdateCheckDesc": "Stop checking for new versions on startup and dashboard. Reduces network requests.",
|
||||||
"currentPassword": "Current Password",
|
"currentPassword": "Current Password",
|
||||||
"passwordChangedSuccess": "Password changed successfully! Please log in again.",
|
"passwordChangedSuccess": "Password changed successfully! Please log in again.",
|
||||||
"failedToChangePassword": "Failed to change password. Please check your current password and try again.",
|
"failedToChangePassword": "Failed to change password. Please check your current password and try again.",
|
||||||
@@ -1939,7 +2035,9 @@
|
|||||||
"themeDark": "Dark",
|
"themeDark": "Dark",
|
||||||
"themeSystem": "System",
|
"themeSystem": "System",
|
||||||
"appearanceDesc": "Select the color theme for the application",
|
"appearanceDesc": "Select the color theme for the application",
|
||||||
"terminalSyntaxHighlightingDesc": "Automatically highlight commands, paths, IPs, and log levels in terminal output"
|
"terminalSyntaxHighlightingDesc": "Automatically highlight commands, paths, IPs, and log levels in terminal output",
|
||||||
|
"enableCommandPaletteShortcut": "Enable Command Palette Shortcut",
|
||||||
|
"enableCommandPaletteShortcutDesc": "Double-tap left Shift to open the Command Palette for quick access to hosts"
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
"failedToLoadVersionInfo": "Failed to load version information"
|
"failedToLoadVersionInfo": "Failed to load version information"
|
||||||
@@ -2163,7 +2261,20 @@
|
|||||||
"noServerData": "No server data available",
|
"noServerData": "No server data available",
|
||||||
"cpu": "CPU",
|
"cpu": "CPU",
|
||||||
"ram": "RAM",
|
"ram": "RAM",
|
||||||
"notAvailable": "N/A"
|
"notAvailable": "N/A",
|
||||||
|
"customizeLayout": "Customize Dashboard",
|
||||||
|
"dashboardSettings": "Dashboard Settings",
|
||||||
|
"enableDisableCards": "Enable/Disable Cards",
|
||||||
|
"gridColumns": "Grid Columns",
|
||||||
|
"column": "Column",
|
||||||
|
"columns": "Columns",
|
||||||
|
"resetLayout": "Reset to Default",
|
||||||
|
"serverOverviewCard": "Server Overview",
|
||||||
|
"recentActivityCard": "Recent Activity",
|
||||||
|
"networkGraphCard": "Network Graph",
|
||||||
|
"quickActionsCard": "Quick Actions",
|
||||||
|
"serverStatsCard": "Server Stats",
|
||||||
|
"networkGraph": "Network Graph"
|
||||||
},
|
},
|
||||||
"rbac": {
|
"rbac": {
|
||||||
"shareHost": "Share Host",
|
"shareHost": "Share Host",
|
||||||
|
|||||||
2402
src/locales/es.json
2402
src/locales/es.json
File diff suppressed because it is too large
Load Diff
2402
src/locales/fr.json
2402
src/locales/fr.json
File diff suppressed because it is too large
Load Diff
2402
src/locales/he.json
2402
src/locales/he.json
File diff suppressed because it is too large
Load Diff
2402
src/locales/hi.json
2402
src/locales/hi.json
File diff suppressed because it is too large
Load Diff
2402
src/locales/id.json
2402
src/locales/id.json
File diff suppressed because it is too large
Load Diff
2402
src/locales/it.json
2402
src/locales/it.json
File diff suppressed because it is too large
Load Diff
2402
src/locales/ja.json
2402
src/locales/ja.json
File diff suppressed because it is too large
Load Diff
2402
src/locales/ko.json
2402
src/locales/ko.json
File diff suppressed because it is too large
Load Diff
2402
src/locales/nb.json
2402
src/locales/nb.json
File diff suppressed because it is too large
Load Diff
2402
src/locales/nl.json
2402
src/locales/nl.json
File diff suppressed because it is too large
Load Diff
2402
src/locales/pl.json
2402
src/locales/pl.json
File diff suppressed because it is too large
Load Diff
2402
src/locales/pt.json
2402
src/locales/pt.json
File diff suppressed because it is too large
Load Diff
2402
src/locales/ro.json
2402
src/locales/ro.json
File diff suppressed because it is too large
Load Diff
2402
src/locales/ru.json
2402
src/locales/ru.json
File diff suppressed because it is too large
Load Diff
2402
src/locales/sv.json
2402
src/locales/sv.json
File diff suppressed because it is too large
Load Diff
2402
src/locales/th.json
2402
src/locales/th.json
File diff suppressed because it is too large
Load Diff
2402
src/locales/tr.json
2402
src/locales/tr.json
File diff suppressed because it is too large
Load Diff
@@ -1183,7 +1183,7 @@
|
|||||||
"pause": "বিরতি",
|
"pause": "বিরতি",
|
||||||
"restart": "পুনরারম্ভ করুন",
|
"restart": "পুনরারম্ভ করুন",
|
||||||
"removeContainer": "কন্টেইনার সরান",
|
"removeContainer": "কন্টেইনার সরান",
|
||||||
"confirmRemoveContainer": "আপনি কি নিশ্চিত যে আপনি পাত্রে অপসারণ করতে চান {{name}}?",
|
"confirmRemoveContainer": "Are you sure you want to remove container \"{{name}}\"?",
|
||||||
"runningContainerWarning": "সতর্কতা: এই কন্টেইনারটি বর্তমানে চলছে এবং জোর করে সরিয়ে ফেলা হবে।",
|
"runningContainerWarning": "সতর্কতা: এই কন্টেইনারটি বর্তমানে চলছে এবং জোর করে সরিয়ে ফেলা হবে।",
|
||||||
"removing": "অপসারণ:",
|
"removing": "অপসারণ:",
|
||||||
"containerNotFound": "কন্টেইনারটি পাওয়া যায়নি",
|
"containerNotFound": "কন্টেইনারটি পাওয়া যায়নি",
|
||||||
|
|||||||
@@ -1371,6 +1371,10 @@
|
|||||||
"downloadSuccess": "File downloaded successfully",
|
"downloadSuccess": "File downloaded successfully",
|
||||||
"downloadFailed": "File download failed",
|
"downloadFailed": "File download failed",
|
||||||
"permissionDenied": "Permission denied",
|
"permissionDenied": "Permission denied",
|
||||||
|
"sudoAuthFailed": "Sudo authentication failed. Please check your password.",
|
||||||
|
"accessDirectory": "access this directory",
|
||||||
|
"deleteOperation": "delete these items",
|
||||||
|
"sudoOperationFailed": "Sudo operation failed",
|
||||||
"checkDockerLogs": "Check the Docker logs for detailed error information",
|
"checkDockerLogs": "Check the Docker logs for detailed error information",
|
||||||
"internalServerError": "Internal server error occurred",
|
"internalServerError": "Internal server error occurred",
|
||||||
"serverError": "Server Error",
|
"serverError": "Server Error",
|
||||||
|
|||||||
@@ -1370,7 +1370,11 @@
|
|||||||
"uploadFailed": "文件上傳失敗",
|
"uploadFailed": "文件上傳失敗",
|
||||||
"downloadSuccess": "文件下載成功",
|
"downloadSuccess": "文件下載成功",
|
||||||
"downloadFailed": "文件下載失敗",
|
"downloadFailed": "文件下載失敗",
|
||||||
"permissionDenied": "沒有權限",
|
"permissionDenied": "没有权限",
|
||||||
|
"sudoAuthFailed": "Sudo 认证失败,请检查密码",
|
||||||
|
"accessDirectory": "访问此目录",
|
||||||
|
"deleteOperation": "删除这些项目",
|
||||||
|
"sudoOperationFailed": "Sudo 操作失败",
|
||||||
"checkDockerLogs": "查看 Docker 日誌以取得詳細的錯誤訊息",
|
"checkDockerLogs": "查看 Docker 日誌以取得詳細的錯誤訊息",
|
||||||
"internalServerError": "發生內部伺服器錯誤",
|
"internalServerError": "發生內部伺服器錯誤",
|
||||||
"serverError": "伺服器錯誤",
|
"serverError": "伺服器錯誤",
|
||||||
|
|||||||
2402
src/locales/uk.json
2402
src/locales/uk.json
File diff suppressed because it is too large
Load Diff
2402
src/locales/vi.json
2402
src/locales/vi.json
File diff suppressed because it is too large
Load Diff
2402
src/locales/zh.json
2402
src/locales/zh.json
File diff suppressed because it is too large
Load Diff
30
src/main.tsx
30
src/main.tsx
@@ -8,6 +8,23 @@ import { ThemeProvider } from "@/components/theme-provider";
|
|||||||
import { ElectronVersionCheck } from "@/ui/desktop/user/ElectronVersionCheck.tsx";
|
import { ElectronVersionCheck } from "@/ui/desktop/user/ElectronVersionCheck.tsx";
|
||||||
import "./i18n/i18n";
|
import "./i18n/i18n";
|
||||||
import { isElectron } from "./ui/main-axios.ts";
|
import { isElectron } from "./ui/main-axios.ts";
|
||||||
|
import HostManagerApp from "./ui/desktop/apps/HostManagerApp.tsx";
|
||||||
|
import NetworkGraphApp from "./ui/desktop/apps/NetworkGraphApp.tsx";
|
||||||
|
|
||||||
|
const FullscreenApp: React.FC = () => {
|
||||||
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
|
const view = searchParams.get('view');
|
||||||
|
|
||||||
|
switch (view) {
|
||||||
|
case 'host-manager':
|
||||||
|
return <HostManagerApp />;
|
||||||
|
case 'network-graph':
|
||||||
|
return <NetworkGraphApp />;
|
||||||
|
default:
|
||||||
|
return <DesktopApp />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
import { useServiceWorker } from "@/hooks/use-service-worker";
|
||||||
|
|
||||||
function useWindowWidth() {
|
function useWindowWidth() {
|
||||||
const [width, setWidth] = useState(window.innerWidth);
|
const [width, setWidth] = useState(window.innerWidth);
|
||||||
@@ -58,11 +75,21 @@ function RootApp() {
|
|||||||
const isMobile = width < 768;
|
const isMobile = width < 768;
|
||||||
const [showVersionCheck, setShowVersionCheck] = useState(true);
|
const [showVersionCheck, setShowVersionCheck] = useState(true);
|
||||||
|
|
||||||
|
// PWA Service Worker registration (production web only)
|
||||||
|
useServiceWorker();
|
||||||
|
|
||||||
const userAgent =
|
const userAgent =
|
||||||
navigator.userAgent || navigator.vendor || (window as any).opera || "";
|
navigator.userAgent || navigator.vendor || (window as any).opera || "";
|
||||||
const isTermixMobile = /Termix-Mobile/.test(userAgent);
|
const isTermixMobile = /Termix-Mobile/.test(userAgent);
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
|
const isFullscreen = searchParams.has('view');
|
||||||
|
|
||||||
const renderApp = () => {
|
const renderApp = () => {
|
||||||
|
if (isFullscreen) {
|
||||||
|
return <FullscreenApp />;
|
||||||
|
}
|
||||||
|
|
||||||
if (isElectron()) {
|
if (isElectron()) {
|
||||||
return <DesktopApp />;
|
return <DesktopApp />;
|
||||||
}
|
}
|
||||||
@@ -94,7 +121,7 @@ function RootApp() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="relative min-h-screen" style={{ zIndex: 1 }}>
|
<div className="relative min-h-screen" style={{ zIndex: 1 }}>
|
||||||
{isElectron() && showVersionCheck ? (
|
{isElectron() && showVersionCheck && !isFullscreen ? (
|
||||||
<ElectronVersionCheck
|
<ElectronVersionCheck
|
||||||
onContinue={() => setShowVersionCheck(false)}
|
onContinue={() => setShowVersionCheck(false)}
|
||||||
isAuthenticated={false}
|
isAuthenticated={false}
|
||||||
@@ -114,3 +141,4 @@ createRoot(document.getElementById("root")!).render(
|
|||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ export interface SSHHost {
|
|||||||
enableTunnel: boolean;
|
enableTunnel: boolean;
|
||||||
enableFileManager: boolean;
|
enableFileManager: boolean;
|
||||||
enableDocker: boolean;
|
enableDocker: boolean;
|
||||||
|
showTerminalInSidebar: boolean;
|
||||||
|
showFileManagerInSidebar: boolean;
|
||||||
|
showTunnelInSidebar: boolean;
|
||||||
|
showDockerInSidebar: boolean;
|
||||||
|
showServerStatsInSidebar: boolean;
|
||||||
defaultPath: string;
|
defaultPath: string;
|
||||||
tunnelConnections: TunnelConnection[];
|
tunnelConnections: TunnelConnection[];
|
||||||
jumpHosts?: JumpHost[];
|
jumpHosts?: JumpHost[];
|
||||||
@@ -102,6 +107,11 @@ export interface SSHHostData {
|
|||||||
enableTunnel?: boolean;
|
enableTunnel?: boolean;
|
||||||
enableFileManager?: boolean;
|
enableFileManager?: boolean;
|
||||||
enableDocker?: boolean;
|
enableDocker?: boolean;
|
||||||
|
showTerminalInSidebar?: boolean;
|
||||||
|
showFileManagerInSidebar?: boolean;
|
||||||
|
showTunnelInSidebar?: boolean;
|
||||||
|
showDockerInSidebar?: boolean;
|
||||||
|
showServerStatsInSidebar?: boolean;
|
||||||
defaultPath?: string;
|
defaultPath?: string;
|
||||||
forceKeyboardInteractive?: boolean;
|
forceKeyboardInteractive?: boolean;
|
||||||
tunnelConnections?: TunnelConnection[];
|
tunnelConnections?: TunnelConnection[];
|
||||||
@@ -193,6 +203,7 @@ export interface CredentialData {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export interface TunnelConnection {
|
export interface TunnelConnection {
|
||||||
|
tunnelType?: "local" | "remote";
|
||||||
sourcePort: number;
|
sourcePort: number;
|
||||||
endpointPort: number;
|
endpointPort: number;
|
||||||
endpointHost: string;
|
endpointHost: string;
|
||||||
@@ -210,6 +221,7 @@ export interface TunnelConnection {
|
|||||||
|
|
||||||
export interface TunnelConfig {
|
export interface TunnelConfig {
|
||||||
name: string;
|
name: string;
|
||||||
|
tunnelType?: "local" | "remote";
|
||||||
|
|
||||||
sourceHostId: number;
|
sourceHostId: number;
|
||||||
tunnelIndex: number;
|
tunnelIndex: number;
|
||||||
|
|||||||
@@ -6,7 +6,48 @@ export type WidgetType =
|
|||||||
| "uptime"
|
| "uptime"
|
||||||
| "processes"
|
| "processes"
|
||||||
| "system"
|
| "system"
|
||||||
| "login_stats";
|
| "login_stats"
|
||||||
|
| "ports"
|
||||||
|
| "firewall";
|
||||||
|
|
||||||
|
export interface ListeningPort {
|
||||||
|
protocol: "tcp" | "udp";
|
||||||
|
localAddress: string;
|
||||||
|
localPort: number;
|
||||||
|
state?: string;
|
||||||
|
pid?: number;
|
||||||
|
process?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PortsMetrics {
|
||||||
|
source: "ss" | "netstat" | "none";
|
||||||
|
ports: ListeningPort[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FirewallRule {
|
||||||
|
chain: string;
|
||||||
|
target: string;
|
||||||
|
protocol: string;
|
||||||
|
source: string;
|
||||||
|
destination: string;
|
||||||
|
dport?: string;
|
||||||
|
sport?: string;
|
||||||
|
state?: string;
|
||||||
|
interface?: string;
|
||||||
|
extra?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FirewallChain {
|
||||||
|
name: string;
|
||||||
|
policy: string;
|
||||||
|
rules: FirewallRule[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FirewallMetrics {
|
||||||
|
type: "iptables" | "nftables" | "none";
|
||||||
|
status: "active" | "inactive" | "unknown";
|
||||||
|
chains: FirewallChain[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface StatsConfig {
|
export interface StatsConfig {
|
||||||
enabledWidgets: WidgetType[];
|
enabledWidgets: WidgetType[];
|
||||||
|
|||||||
@@ -11,12 +11,17 @@ import { TopNavbar } from "@/ui/desktop/navigation/TopNavbar.tsx";
|
|||||||
import { CommandHistoryProvider } from "@/ui/desktop/apps/features/terminal/command-history/CommandHistoryContext.tsx";
|
import { CommandHistoryProvider } from "@/ui/desktop/apps/features/terminal/command-history/CommandHistoryContext.tsx";
|
||||||
import { AdminSettings } from "@/ui/desktop/apps/admin/AdminSettings.tsx";
|
import { AdminSettings } from "@/ui/desktop/apps/admin/AdminSettings.tsx";
|
||||||
import { UserProfile } from "@/ui/desktop/user/UserProfile.tsx";
|
import { UserProfile } from "@/ui/desktop/user/UserProfile.tsx";
|
||||||
|
import { NetworkGraphCard } from "@/ui/desktop/apps/dashboard/cards/NetworkGraphCard";
|
||||||
import { Toaster } from "@/components/ui/sonner.tsx";
|
import { Toaster } from "@/components/ui/sonner.tsx";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { CommandPalette } from "@/ui/desktop/apps/command-palette/CommandPalette.tsx";
|
import { CommandPalette } from "@/ui/desktop/apps/command-palette/CommandPalette.tsx";
|
||||||
import { getUserInfo } from "@/ui/main-axios.ts";
|
import { getUserInfo, logoutUser, isElectron } from "@/ui/main-axios.ts";
|
||||||
import { useTheme } from "@/components/theme-provider";
|
import { useTheme } from "@/components/theme-provider";
|
||||||
|
import { dbHealthMonitor } from "@/lib/db-health-monitor.ts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
const [username, setUsername] = useState<string | null>(null);
|
const [username, setUsername] = useState<string | null>(null);
|
||||||
const [isAdmin, setIsAdmin] = useState(false);
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
@@ -29,11 +34,12 @@ function AppContent() {
|
|||||||
const [transitionPhase, setTransitionPhase] = useState<
|
const [transitionPhase, setTransitionPhase] = useState<
|
||||||
"idle" | "fadeOut" | "fadeIn"
|
"idle" | "fadeOut" | "fadeIn"
|
||||||
>("idle");
|
>("idle");
|
||||||
const { currentTab, tabs, updateTab } = useTabs();
|
const { currentTab, tabs, updateTab, addTab } = useTabs();
|
||||||
const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
|
const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const [rightSidebarOpen, setRightSidebarOpen] = useState(false);
|
const [rightSidebarOpen, setRightSidebarOpen] = useState(false);
|
||||||
const [rightSidebarWidth, setRightSidebarWidth] = useState(400);
|
const [rightSidebarWidth, setRightSidebarWidth] = useState(400);
|
||||||
|
const [dbConnectionFailed, setDbConnectionFailed] = useState(false);
|
||||||
|
|
||||||
const isDarkMode =
|
const isDarkMode =
|
||||||
theme === "dark" ||
|
theme === "dark" ||
|
||||||
@@ -45,12 +51,49 @@ function AppContent() {
|
|||||||
|
|
||||||
const lastAltPressTime = useRef(0);
|
const lastAltPressTime = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleDatabaseConnectionLost = () => {
|
||||||
|
setDbConnectionFailed(true);
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDatabaseConnectionRestored = () => {
|
||||||
|
setDbConnectionFailed(false);
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
dbHealthMonitor.on(
|
||||||
|
"database-connection-lost",
|
||||||
|
handleDatabaseConnectionLost,
|
||||||
|
);
|
||||||
|
dbHealthMonitor.on(
|
||||||
|
"database-connection-restored",
|
||||||
|
handleDatabaseConnectionRestored,
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
dbHealthMonitor.off(
|
||||||
|
"database-connection-lost",
|
||||||
|
handleDatabaseConnectionLost,
|
||||||
|
);
|
||||||
|
dbHealthMonitor.off(
|
||||||
|
"database-connection-restored",
|
||||||
|
handleDatabaseConnectionRestored,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.code === "ShiftLeft") {
|
if (event.code === "ShiftLeft") {
|
||||||
if (event.repeat) {
|
if (event.repeat) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const shortcutEnabled =
|
||||||
|
localStorage.getItem("commandPaletteShortcutEnabled") !== "false";
|
||||||
|
if (!shortcutEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastShiftPressTime.current < 300) {
|
if (now - lastShiftPressTime.current < 300) {
|
||||||
setIsCommandPaletteOpen((isOpen) => !isOpen);
|
setIsCommandPaletteOpen((isOpen) => !isOpen);
|
||||||
@@ -86,6 +129,52 @@ function AppContent() {
|
|||||||
};
|
};
|
||||||
}, [theme, setTheme]);
|
}, [theme, setTheme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const path = window.location.pathname;
|
||||||
|
// New format: /terminal/{hostNameOrId}
|
||||||
|
const terminalMatch = path.match(/^\/terminal\/([a-zA-Z0-9_-]+)$/);
|
||||||
|
// Legacy format: /hosts/{id}/terminal (backward compatible)
|
||||||
|
const legacyMatch = path.match(/^\/hosts\/([a-zA-Z0-9_-]+)\/terminal$/);
|
||||||
|
const hostIdentifier = terminalMatch?.[1] || legacyMatch?.[1];
|
||||||
|
|
||||||
|
if (hostIdentifier) {
|
||||||
|
const openTerminal = async () => {
|
||||||
|
try {
|
||||||
|
const { getSSHHostById, getSSHHosts } =
|
||||||
|
await import("@/ui/main-axios.ts");
|
||||||
|
let host = null;
|
||||||
|
|
||||||
|
// Pure numeric → lookup by ID
|
||||||
|
if (/^\d+$/.test(hostIdentifier)) {
|
||||||
|
host = await getSSHHostById(parseInt(hostIdentifier, 10));
|
||||||
|
} else {
|
||||||
|
// Non-numeric → lookup by name (first match)
|
||||||
|
const hosts = await getSSHHosts();
|
||||||
|
host =
|
||||||
|
hosts.find((h: { name?: string }) => h.name === hostIdentifier) ||
|
||||||
|
null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (host) {
|
||||||
|
addTab({
|
||||||
|
type: "terminal",
|
||||||
|
title: host.name || host.ip,
|
||||||
|
data: { host, initialCommand: "" },
|
||||||
|
});
|
||||||
|
// Clean URL to prevent re-opening on refresh
|
||||||
|
window.history.replaceState({}, "", "/");
|
||||||
|
} else {
|
||||||
|
toast.error(`Host "${hostIdentifier}" not found`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to open terminal:", error);
|
||||||
|
toast.error("Failed to open terminal for host");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
openTerminal();
|
||||||
|
}
|
||||||
|
}, [addTab]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkAuth = () => {
|
const checkAuth = () => {
|
||||||
setAuthLoading(true);
|
setAuthLoading(true);
|
||||||
@@ -131,8 +220,6 @@ function AppContent() {
|
|||||||
localStorage.setItem("topNavbarOpen", JSON.stringify(isTopbarOpen));
|
localStorage.setItem("topNavbarOpen", JSON.stringify(isTopbarOpen));
|
||||||
}, [isTopbarOpen]);
|
}, [isTopbarOpen]);
|
||||||
|
|
||||||
const handleSelectView = () => {};
|
|
||||||
|
|
||||||
const handleAuthSuccess = useCallback(
|
const handleAuthSuccess = useCallback(
|
||||||
(authData: {
|
(authData: {
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
@@ -163,7 +250,6 @@ function AppContent() {
|
|||||||
|
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const { logoutUser, isElectron } = await import("@/ui/main-axios.ts");
|
|
||||||
await logoutUser();
|
await logoutUser();
|
||||||
|
|
||||||
if (isElectron()) {
|
if (isElectron()) {
|
||||||
@@ -188,11 +274,12 @@ function AppContent() {
|
|||||||
const showSshManager = currentTabData?.type === "ssh_manager";
|
const showSshManager = currentTabData?.type === "ssh_manager";
|
||||||
const showAdmin = currentTabData?.type === "admin";
|
const showAdmin = currentTabData?.type === "admin";
|
||||||
const showProfile = currentTabData?.type === "user_profile";
|
const showProfile = currentTabData?.type === "user_profile";
|
||||||
|
const showNetworkGraph = currentTabData?.type === "network_graph";
|
||||||
|
|
||||||
if (authLoading) {
|
if (authLoading && !dbConnectionFailed) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="h-screen w-screen flex items-center justify-center"
|
className="fixed inset-0 flex items-center justify-center"
|
||||||
style={{
|
style={{
|
||||||
background: "var(--bg-elevated)",
|
background: "var(--bg-elevated)",
|
||||||
backgroundImage: `repeating-linear-gradient(
|
backgroundImage: `repeating-linear-gradient(
|
||||||
@@ -204,13 +291,44 @@ function AppContent() {
|
|||||||
)`,
|
)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="text-center">
|
<div className="w-[420px] max-w-full p-8 flex flex-col backdrop-blur-sm bg-card/50 rounded-2xl shadow-xl border-2 border-edge overflow-y-auto thin-scrollbar my-2 animate-in fade-in zoom-in-95 duration-300">
|
||||||
<div className="w-16 h-16 border-4 border-primary/30 border-t-primary rounded-full animate-spin mx-auto" />
|
<div className="flex items-center justify-center h-32">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{t("common.checkingAuthentication")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dbConnectionFailed) {
|
||||||
|
return (
|
||||||
|
<div className="h-screen w-screen overflow-hidden bg-background">
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center z-[10000] bg-background">
|
||||||
|
<Dashboard
|
||||||
|
isAuthenticated={false}
|
||||||
|
authLoading={false}
|
||||||
|
onAuthSuccess={handleAuthSuccess}
|
||||||
|
isTopbarOpen={isTopbarOpen}
|
||||||
|
onSelectView={() => {}}
|
||||||
|
initialDbError="Database connection failed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Toaster
|
||||||
|
position="bottom-right"
|
||||||
|
richColors={false}
|
||||||
|
closeButton
|
||||||
|
duration={5000}
|
||||||
|
offset={20}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-screen overflow-hidden bg-background">
|
<div className="h-screen w-screen overflow-hidden bg-background">
|
||||||
<CommandPalette
|
<CommandPalette
|
||||||
@@ -220,7 +338,6 @@ function AppContent() {
|
|||||||
{!isAuthenticated && (
|
{!isAuthenticated && (
|
||||||
<div className="fixed inset-0 flex items-center justify-center z-[10000] bg-background">
|
<div className="fixed inset-0 flex items-center justify-center z-[10000] bg-background">
|
||||||
<Dashboard
|
<Dashboard
|
||||||
onSelectView={handleSelectView}
|
|
||||||
isAuthenticated={isAuthenticated}
|
isAuthenticated={isAuthenticated}
|
||||||
authLoading={authLoading}
|
authLoading={authLoading}
|
||||||
onAuthSuccess={handleAuthSuccess}
|
onAuthSuccess={handleAuthSuccess}
|
||||||
@@ -231,7 +348,6 @@ function AppContent() {
|
|||||||
|
|
||||||
{isAuthenticated && (
|
{isAuthenticated && (
|
||||||
<LeftSidebar
|
<LeftSidebar
|
||||||
onSelectView={handleSelectView}
|
|
||||||
disabled={!isAuthenticated || authLoading}
|
disabled={!isAuthenticated || authLoading}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
username={username}
|
username={username}
|
||||||
@@ -251,7 +367,6 @@ function AppContent() {
|
|||||||
{showHome && (
|
{showHome && (
|
||||||
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
|
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
|
||||||
<Dashboard
|
<Dashboard
|
||||||
onSelectView={handleSelectView}
|
|
||||||
isAuthenticated={isAuthenticated}
|
isAuthenticated={isAuthenticated}
|
||||||
authLoading={authLoading}
|
authLoading={authLoading}
|
||||||
onAuthSuccess={handleAuthSuccess}
|
onAuthSuccess={handleAuthSuccess}
|
||||||
@@ -265,7 +380,6 @@ function AppContent() {
|
|||||||
{showSshManager && (
|
{showSshManager && (
|
||||||
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
|
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
|
||||||
<HostManager
|
<HostManager
|
||||||
onSelectView={handleSelectView}
|
|
||||||
isTopbarOpen={isTopbarOpen}
|
isTopbarOpen={isTopbarOpen}
|
||||||
initialTab={currentTabData?.initialTab}
|
initialTab={currentTabData?.initialTab}
|
||||||
hostConfig={currentTabData?.hostConfig}
|
hostConfig={currentTabData?.hostConfig}
|
||||||
@@ -298,6 +412,16 @@ function AppContent() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showNetworkGraph && (
|
||||||
|
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
|
||||||
|
<NetworkGraphCard
|
||||||
|
isTopbarOpen={isTopbarOpen}
|
||||||
|
rightSidebarOpen={rightSidebarOpen}
|
||||||
|
rightSidebarWidth={rightSidebarWidth}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<TopNavbar
|
<TopNavbar
|
||||||
isTopbarOpen={isTopbarOpen}
|
isTopbarOpen={isTopbarOpen}
|
||||||
setIsTopbarOpen={setIsTopbarOpen}
|
setIsTopbarOpen={setIsTopbarOpen}
|
||||||
|
|||||||
12
src/ui/desktop/apps/HostManagerApp.tsx
Normal file
12
src/ui/desktop/apps/HostManagerApp.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { HostManager } from "@/ui/desktop/apps/host-manager/hosts/HostManager";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const HostManagerApp: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-screen">
|
||||||
|
<HostManager isTopbarOpen={false} onSelectView={() => {}} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HostManagerApp;
|
||||||
12
src/ui/desktop/apps/NetworkGraphApp.tsx
Normal file
12
src/ui/desktop/apps/NetworkGraphApp.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { NetworkGraphCard } from "@/ui/desktop/apps/dashboard/cards/NetworkGraphCard";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const NetworkGraphApp: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-screen flex flex-col">
|
||||||
|
<NetworkGraphCard />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NetworkGraphApp;
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
getAdminOIDCConfig,
|
getAdminOIDCConfig,
|
||||||
getRegistrationAllowed,
|
getRegistrationAllowed,
|
||||||
getPasswordLoginAllowed,
|
getPasswordLoginAllowed,
|
||||||
|
getPasswordResetAllowed,
|
||||||
getUserList,
|
getUserList,
|
||||||
getUserInfo,
|
getUserInfo,
|
||||||
isElectron,
|
isElectron,
|
||||||
@@ -48,6 +49,7 @@ export function AdminSettings({
|
|||||||
|
|
||||||
const [allowRegistration, setAllowRegistration] = React.useState(true);
|
const [allowRegistration, setAllowRegistration] = React.useState(true);
|
||||||
const [allowPasswordLogin, setAllowPasswordLogin] = React.useState(true);
|
const [allowPasswordLogin, setAllowPasswordLogin] = React.useState(true);
|
||||||
|
const [allowPasswordReset, setAllowPasswordReset] = React.useState(true);
|
||||||
|
|
||||||
const [oidcConfig, setOidcConfig] = React.useState({
|
const [oidcConfig, setOidcConfig] = React.useState({
|
||||||
client_id: "",
|
client_id: "",
|
||||||
@@ -193,6 +195,28 @@ export function AdminSettings({
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isElectron()) {
|
||||||
|
const serverUrl = (window as { configuredServerUrl?: string })
|
||||||
|
.configuredServerUrl;
|
||||||
|
if (!serverUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getPasswordResetAllowed()
|
||||||
|
.then((res) => {
|
||||||
|
if (typeof res === "boolean") {
|
||||||
|
setAllowPasswordReset(res);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (err.code !== "NO_SERVER_CONFIGURED") {
|
||||||
|
console.warn("Failed to fetch password reset status", err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const fetchUsers = async () => {
|
const fetchUsers = async () => {
|
||||||
if (isElectron()) {
|
if (isElectron()) {
|
||||||
const serverUrl = (window as { configuredServerUrl?: string })
|
const serverUrl = (window as { configuredServerUrl?: string })
|
||||||
@@ -367,6 +391,8 @@ export function AdminSettings({
|
|||||||
setAllowRegistration={setAllowRegistration}
|
setAllowRegistration={setAllowRegistration}
|
||||||
allowPasswordLogin={allowPasswordLogin}
|
allowPasswordLogin={allowPasswordLogin}
|
||||||
setAllowPasswordLogin={setAllowPasswordLogin}
|
setAllowPasswordLogin={setAllowPasswordLogin}
|
||||||
|
allowPasswordReset={allowPasswordReset}
|
||||||
|
setAllowPasswordReset={setAllowPasswordReset}
|
||||||
oidcConfig={oidcConfig}
|
oidcConfig={oidcConfig}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
|||||||
import {
|
import {
|
||||||
updateRegistrationAllowed,
|
updateRegistrationAllowed,
|
||||||
updatePasswordLoginAllowed,
|
updatePasswordLoginAllowed,
|
||||||
|
updatePasswordResetAllowed,
|
||||||
} from "@/ui/main-axios.ts";
|
} from "@/ui/main-axios.ts";
|
||||||
|
|
||||||
interface GeneralSettingsTabProps {
|
interface GeneralSettingsTabProps {
|
||||||
@@ -13,6 +14,8 @@ interface GeneralSettingsTabProps {
|
|||||||
setAllowRegistration: (value: boolean) => void;
|
setAllowRegistration: (value: boolean) => void;
|
||||||
allowPasswordLogin: boolean;
|
allowPasswordLogin: boolean;
|
||||||
setAllowPasswordLogin: (value: boolean) => void;
|
setAllowPasswordLogin: (value: boolean) => void;
|
||||||
|
allowPasswordReset: boolean;
|
||||||
|
setAllowPasswordReset: (value: boolean) => void;
|
||||||
oidcConfig: {
|
oidcConfig: {
|
||||||
client_id: string;
|
client_id: string;
|
||||||
client_secret: string;
|
client_secret: string;
|
||||||
@@ -27,6 +30,8 @@ export function GeneralSettingsTab({
|
|||||||
setAllowRegistration,
|
setAllowRegistration,
|
||||||
allowPasswordLogin,
|
allowPasswordLogin,
|
||||||
setAllowPasswordLogin,
|
setAllowPasswordLogin,
|
||||||
|
allowPasswordReset,
|
||||||
|
setAllowPasswordReset,
|
||||||
oidcConfig,
|
oidcConfig,
|
||||||
}: GeneralSettingsTabProps): React.ReactElement {
|
}: GeneralSettingsTabProps): React.ReactElement {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -34,6 +39,7 @@ export function GeneralSettingsTab({
|
|||||||
|
|
||||||
const [regLoading, setRegLoading] = React.useState(false);
|
const [regLoading, setRegLoading] = React.useState(false);
|
||||||
const [passwordLoginLoading, setPasswordLoginLoading] = React.useState(false);
|
const [passwordLoginLoading, setPasswordLoginLoading] = React.useState(false);
|
||||||
|
const [passwordResetLoading, setPasswordResetLoading] = React.useState(false);
|
||||||
|
|
||||||
const handleToggleRegistration = async (checked: boolean) => {
|
const handleToggleRegistration = async (checked: boolean) => {
|
||||||
setRegLoading(true);
|
setRegLoading(true);
|
||||||
@@ -96,6 +102,16 @@ export function GeneralSettingsTab({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTogglePasswordReset = async (checked: boolean) => {
|
||||||
|
setPasswordResetLoading(true);
|
||||||
|
try {
|
||||||
|
await updatePasswordResetAllowed(checked);
|
||||||
|
setAllowPasswordReset(checked);
|
||||||
|
} finally {
|
||||||
|
setPasswordResetLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||||
<h3 className="text-lg font-semibold">{t("admin.userRegistration")}</h3>
|
<h3 className="text-lg font-semibold">{t("admin.userRegistration")}</h3>
|
||||||
@@ -120,6 +136,19 @@ export function GeneralSettingsTab({
|
|||||||
/>
|
/>
|
||||||
{t("admin.allowPasswordLogin")}
|
{t("admin.allowPasswordLogin")}
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={allowPasswordReset}
|
||||||
|
onCheckedChange={handleTogglePasswordReset}
|
||||||
|
disabled={passwordResetLoading || !allowPasswordLogin}
|
||||||
|
/>
|
||||||
|
{t("admin.allowPasswordReset")}
|
||||||
|
{!allowPasswordLogin && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
({t("admin.requiresPasswordLogin")})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Button } from "@/components/ui/button.tsx";
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
|
import { ButtonGroup } from "@/components/ui/button-group.tsx";
|
||||||
|
|
||||||
interface SSHHost {
|
interface SSHHost {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -364,19 +365,90 @@ export function CommandPalette({
|
|||||||
}}
|
}}
|
||||||
className="flex items-center justify-between"
|
className="flex items-center justify-between"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
<Server className="h-4 w-4" />
|
<Server className="h-4 w-4 flex-shrink-0" />
|
||||||
<span>{title}</span>
|
<span className="truncate">{title}</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<ButtonGroup
|
||||||
className="flex items-center gap-1"
|
className="flex-shrink-0"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
|
{host.enableTerminal &&
|
||||||
|
(host.showTerminalInSidebar ?? true) && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="!px-2 h-7 border-1 border-edge"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleHostTerminalClick(host);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Terminal className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{host.enableFileManager &&
|
||||||
|
(host.showFileManagerInSidebar ?? false) && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="!px-2 h-7 border-1 border-edge"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleHostFileManagerClick(host);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FolderOpen className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{host.enableTunnel &&
|
||||||
|
hasTunnelConnections &&
|
||||||
|
(host.showTunnelInSidebar ?? false) && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="!px-2 h-7 border-1 border-edge"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleHostTunnelClick(host);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowDownUp className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{host.enableDocker &&
|
||||||
|
(host.showDockerInSidebar ?? false) && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="!px-2 h-7 border-1 border-edge"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleHostDockerClick(host);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Container className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{shouldShowMetrics &&
|
||||||
|
(host.showServerStatsInSidebar ?? false) && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="!px-2 h-7 border-1 border-edge"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleHostServerDetailsClick(host);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Server className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<DropdownMenu modal={false}>
|
<DropdownMenu modal={false}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="!px-2 h-7 border-1 border-edge"
|
className="!px-2 h-7 border-1 border-edge rounded-l-none border-l-0"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<EllipsisVertical className="h-3 w-3" />
|
<EllipsisVertical className="h-3 w-3" />
|
||||||
@@ -387,62 +459,82 @@ export function CommandPalette({
|
|||||||
side="right"
|
side="right"
|
||||||
className="w-56 bg-canvas border-edge text-foreground"
|
className="w-56 bg-canvas border-edge text-foreground"
|
||||||
>
|
>
|
||||||
{shouldShowMetrics && (
|
{host.enableTerminal &&
|
||||||
<DropdownMenuItem
|
!(host.showTerminalInSidebar ?? true) && (
|
||||||
onClick={(e) => {
|
<DropdownMenuItem
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
handleHostServerDetailsClick(host);
|
e.stopPropagation();
|
||||||
}}
|
handleHostTerminalClick(host);
|
||||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
}}
|
||||||
>
|
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||||
<Server className="h-4 w-4" />
|
>
|
||||||
<span className="flex-1">
|
<Terminal className="h-4 w-4" />
|
||||||
{t("hosts.openServerStats")}
|
<span className="flex-1">
|
||||||
</span>
|
{t("hosts.openTerminal")}
|
||||||
</DropdownMenuItem>
|
</span>
|
||||||
)}
|
</DropdownMenuItem>
|
||||||
{host.enableFileManager && (
|
)}
|
||||||
<DropdownMenuItem
|
{shouldShowMetrics &&
|
||||||
onClick={(e) => {
|
!(host.showServerStatsInSidebar ?? false) && (
|
||||||
e.stopPropagation();
|
<DropdownMenuItem
|
||||||
handleHostFileManagerClick(host);
|
onClick={(e) => {
|
||||||
}}
|
e.stopPropagation();
|
||||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
handleHostServerDetailsClick(host);
|
||||||
>
|
}}
|
||||||
<FolderOpen className="h-4 w-4" />
|
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||||
<span className="flex-1">
|
>
|
||||||
{t("hosts.openFileManager")}
|
<Server className="h-4 w-4" />
|
||||||
</span>
|
<span className="flex-1">
|
||||||
</DropdownMenuItem>
|
{t("hosts.openServerStats")}
|
||||||
)}
|
</span>
|
||||||
{host.enableTunnel && hasTunnelConnections && (
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
)}
|
||||||
onClick={(e) => {
|
{host.enableFileManager &&
|
||||||
e.stopPropagation();
|
!(host.showFileManagerInSidebar ?? false) && (
|
||||||
handleHostTunnelClick(host);
|
<DropdownMenuItem
|
||||||
}}
|
onClick={(e) => {
|
||||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
e.stopPropagation();
|
||||||
>
|
handleHostFileManagerClick(host);
|
||||||
<ArrowDownUp className="h-4 w-4" />
|
}}
|
||||||
<span className="flex-1">
|
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||||
{t("hosts.openTunnels")}
|
>
|
||||||
</span>
|
<FolderOpen className="h-4 w-4" />
|
||||||
</DropdownMenuItem>
|
<span className="flex-1">
|
||||||
)}
|
{t("hosts.openFileManager")}
|
||||||
{host.enableDocker && (
|
</span>
|
||||||
<DropdownMenuItem
|
</DropdownMenuItem>
|
||||||
onClick={(e) => {
|
)}
|
||||||
e.stopPropagation();
|
{host.enableTunnel &&
|
||||||
handleHostDockerClick(host);
|
hasTunnelConnections &&
|
||||||
}}
|
!(host.showTunnelInSidebar ?? false) && (
|
||||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
<DropdownMenuItem
|
||||||
>
|
onClick={(e) => {
|
||||||
<Container className="h-4 w-4" />
|
e.stopPropagation();
|
||||||
<span className="flex-1">
|
handleHostTunnelClick(host);
|
||||||
{t("hosts.openDocker")}
|
}}
|
||||||
</span>
|
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||||
</DropdownMenuItem>
|
>
|
||||||
)}
|
<ArrowDownUp className="h-4 w-4" />
|
||||||
|
<span className="flex-1">
|
||||||
|
{t("hosts.openTunnels")}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{host.enableDocker &&
|
||||||
|
!(host.showDockerInSidebar ?? false) && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleHostDockerClick(host);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||||
|
>
|
||||||
|
<Container className="h-4 w-4" />
|
||||||
|
<span className="flex-1">
|
||||||
|
{t("hosts.openDocker")}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -455,7 +547,7 @@ export function CommandPalette({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</ButtonGroup>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { Auth } from "@/ui/desktop/authentication/Auth.tsx";
|
import { Auth } from "@/ui/desktop/authentication/Auth.tsx";
|
||||||
import { UpdateLog } from "@/ui/desktop/apps/dashboard/apps/UpdateLog.tsx";
|
|
||||||
import { AlertManager } from "@/ui/desktop/apps/dashboard/apps/alerts/AlertManager.tsx";
|
import { AlertManager } from "@/ui/desktop/apps/dashboard/apps/alerts/AlertManager.tsx";
|
||||||
import { Button } from "@/components/ui/button.tsx";
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
import {
|
import {
|
||||||
@@ -10,7 +9,6 @@ import {
|
|||||||
getUptime,
|
getUptime,
|
||||||
getVersionInfo,
|
getVersionInfo,
|
||||||
getSSHHosts,
|
getSSHHosts,
|
||||||
getTunnelStatuses,
|
|
||||||
getCredentials,
|
getCredentials,
|
||||||
getRecentActivity,
|
getRecentActivity,
|
||||||
resetRecentActivity,
|
resetRecentActivity,
|
||||||
@@ -20,29 +18,16 @@ import {
|
|||||||
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||||
import { Separator } from "@/components/ui/separator.tsx";
|
import { Separator } from "@/components/ui/separator.tsx";
|
||||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||||
import { Kbd, KbdGroup } from "@/components/ui/kbd";
|
import { Kbd } from "@/components/ui/kbd";
|
||||||
import {
|
|
||||||
ChartLine,
|
|
||||||
Clock,
|
|
||||||
Database,
|
|
||||||
FastForward,
|
|
||||||
History,
|
|
||||||
Key,
|
|
||||||
Network,
|
|
||||||
Server,
|
|
||||||
UserPlus,
|
|
||||||
Settings,
|
|
||||||
User,
|
|
||||||
Loader2,
|
|
||||||
Terminal,
|
|
||||||
FolderOpen,
|
|
||||||
Activity,
|
|
||||||
Container,
|
|
||||||
ArrowDownUp,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Status } from "@/components/ui/shadcn-io/status";
|
|
||||||
import { BsLightning } from "react-icons/bs";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Settings as SettingsIcon } from "lucide-react";
|
||||||
|
import { ServerOverviewCard } from "@/ui/desktop/apps/dashboard/cards/ServerOverviewCard";
|
||||||
|
import { RecentActivityCard } from "@/ui/desktop/apps/dashboard/cards/RecentActivityCard";
|
||||||
|
import { QuickActionsCard } from "@/ui/desktop/apps/dashboard/cards/QuickActionsCard";
|
||||||
|
import { ServerStatsCard } from "@/ui/desktop/apps/dashboard/cards/ServerStatsCard";
|
||||||
|
import { NetworkGraphCard } from "@/ui/desktop/apps/dashboard/cards/NetworkGraphCard";
|
||||||
|
import { useDashboardPreferences } from "@/ui/desktop/apps/dashboard/hooks/useDashboardPreferences";
|
||||||
|
import { DashboardSettingsDialog } from "@/ui/desktop/apps/dashboard/components/DashboardSettingsDialog";
|
||||||
|
|
||||||
interface DashboardProps {
|
interface DashboardProps {
|
||||||
onSelectView: (view: string) => void;
|
onSelectView: (view: string) => void;
|
||||||
@@ -56,6 +41,7 @@ interface DashboardProps {
|
|||||||
isTopbarOpen: boolean;
|
isTopbarOpen: boolean;
|
||||||
rightSidebarOpen?: boolean;
|
rightSidebarOpen?: boolean;
|
||||||
rightSidebarWidth?: number;
|
rightSidebarWidth?: number;
|
||||||
|
initialDbError?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Dashboard({
|
export function Dashboard({
|
||||||
@@ -63,16 +49,16 @@ export function Dashboard({
|
|||||||
authLoading,
|
authLoading,
|
||||||
onAuthSuccess,
|
onAuthSuccess,
|
||||||
isTopbarOpen,
|
isTopbarOpen,
|
||||||
onSelectView,
|
|
||||||
rightSidebarOpen = false,
|
rightSidebarOpen = false,
|
||||||
rightSidebarWidth = 400,
|
rightSidebarWidth = 400,
|
||||||
|
initialDbError = null,
|
||||||
}: DashboardProps): React.ReactElement {
|
}: DashboardProps): React.ReactElement {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [loggedIn, setLoggedIn] = useState(isAuthenticated);
|
const [loggedIn, setLoggedIn] = useState(isAuthenticated);
|
||||||
const [isAdmin, setIsAdmin] = useState(false);
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
const [, setUsername] = useState<string | null>(null);
|
const [, setUsername] = useState<string | null>(null);
|
||||||
const [userId, setUserId] = useState<string | null>(null);
|
const [userId, setUserId] = useState<string | null>(null);
|
||||||
const [dbError, setDbError] = useState<string | null>(null);
|
const [dbError, setDbError] = useState<string | null>(initialDbError);
|
||||||
|
|
||||||
const [uptime, setUptime] = useState<string>("0d 0h 0m");
|
const [uptime, setUptime] = useState<string>("0d 0h 0m");
|
||||||
const [versionStatus, setVersionStatus] = useState<
|
const [versionStatus, setVersionStatus] = useState<
|
||||||
@@ -92,8 +78,15 @@ export function Dashboard({
|
|||||||
Array<{ id: number; name: string; cpu: number | null; ram: number | null }>
|
Array<{ id: number; name: string; cpu: number | null; ram: number | null }>
|
||||||
>([]);
|
>([]);
|
||||||
const [serverStatsLoading, setServerStatsLoading] = useState<boolean>(true);
|
const [serverStatsLoading, setServerStatsLoading] = useState<boolean>(true);
|
||||||
|
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
||||||
|
|
||||||
const { addTab, setCurrentTab, tabs: tabList, updateTab } = useTabs();
|
const { addTab, setCurrentTab, tabs: tabList, updateTab } = useTabs();
|
||||||
|
const {
|
||||||
|
layout,
|
||||||
|
loading: preferencesLoading,
|
||||||
|
updateLayout,
|
||||||
|
resetLayout,
|
||||||
|
} = useDashboardPreferences();
|
||||||
|
|
||||||
let sidebarState: "expanded" | "collapsed" = "expanded";
|
let sidebarState: "expanded" | "collapsed" = "expanded";
|
||||||
try {
|
try {
|
||||||
@@ -159,9 +152,18 @@ export function Dashboard({
|
|||||||
const uptimeInfo = await getUptime();
|
const uptimeInfo = await getUptime();
|
||||||
setUptime(uptimeInfo.formatted);
|
setUptime(uptimeInfo.formatted);
|
||||||
|
|
||||||
const versionInfo = await getVersionInfo();
|
const updateCheckDisabled =
|
||||||
setVersionText(`v${versionInfo.localVersion}`);
|
localStorage.getItem("disableUpdateCheck") === "true";
|
||||||
setVersionStatus(versionInfo.status || "up_to_date");
|
if (!updateCheckDisabled) {
|
||||||
|
const versionInfo = await getVersionInfo();
|
||||||
|
setVersionText(`v${versionInfo.localVersion}`);
|
||||||
|
if (
|
||||||
|
versionInfo.status === "up_to_date" ||
|
||||||
|
versionInfo.status === "requires_update"
|
||||||
|
) {
|
||||||
|
setVersionStatus(versionInfo.status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await getDatabaseHealth();
|
await getDatabaseHealth();
|
||||||
@@ -424,8 +426,18 @@ export function Dashboard({
|
|||||||
>
|
>
|
||||||
<div className="flex flex-col relative z-10 w-full h-full min-w-0">
|
<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="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-foreground font-semibold shrink-0">
|
<div className="flex flex-row items-center gap-3">
|
||||||
{t("dashboard.title")}
|
<div className="text-2xl text-foreground font-semibold shrink-0">
|
||||||
|
{t("dashboard.title")}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="font-semibold shrink-0 !bg-canvas"
|
||||||
|
onClick={() => setSettingsDialogOpen(true)}
|
||||||
|
>
|
||||||
|
{t("dashboard.customizeLayout")}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row gap-3 flex-wrap min-w-0">
|
<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">
|
<div className="flex flex-col items-center gap-4 justify-center mr-5 min-w-0 shrink">
|
||||||
@@ -484,361 +496,91 @@ export function Dashboard({
|
|||||||
<Separator className="mt-3 p-0.25" />
|
<Separator className="mt-3 p-0.25" />
|
||||||
|
|
||||||
<div className="flex flex-col flex-1 my-5 mx-5 gap-4 min-h-0 min-w-0">
|
<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">
|
{!preferencesLoading && layout && (
|
||||||
<div className="flex-1 min-w-0 border-2 border-edge rounded-md bg-elevated flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
|
<div
|
||||||
<div className="flex flex-col mx-3 my-2 overflow-y-auto overflow-x-hidden thin-scrollbar">
|
className="grid gap-4 flex-1 min-h-0 auto-rows-fr"
|
||||||
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
|
style={{
|
||||||
<Server className="mr-3" />
|
gridTemplateColumns: `repeat(${layout.gridColumns}, minmax(0, 1fr))`,
|
||||||
{t("dashboard.serverOverview")}
|
}}
|
||||||
</p>
|
>
|
||||||
<div className="bg-canvas w-full h-auto border-2 border-edge rounded-md px-3 py-3">
|
{layout.cards
|
||||||
<div className="flex flex-row items-center justify-between mb-3 min-w-0 gap-2">
|
.filter((card) => card.enabled)
|
||||||
<div className="flex flex-row items-center min-w-0">
|
.sort((a, b) => a.order - b.order)
|
||||||
<History size={20} className="shrink-0" />
|
.map((card) => {
|
||||||
<p className="ml-2 leading-none truncate">
|
if (card.id === "server_overview") {
|
||||||
{t("dashboard.version")}
|
return (
|
||||||
</p>
|
<ServerOverviewCard
|
||||||
</div>
|
key={card.id}
|
||||||
|
loggedIn={loggedIn}
|
||||||
<div className="flex flex-row items-center">
|
versionText={versionText}
|
||||||
<p className="leading-none text-muted-foreground">
|
versionStatus={versionStatus}
|
||||||
{versionText}
|
uptime={uptime}
|
||||||
</p>
|
dbHealth={dbHealth}
|
||||||
<Button
|
totalServers={totalServers}
|
||||||
variant="outline"
|
totalTunnels={totalTunnels}
|
||||||
size="sm"
|
totalCredentials={totalCredentials}
|
||||||
className={`ml-2 text-sm border-1 border-edge ${versionStatus === "up_to_date" ? "text-green-400" : "text-yellow-400"}`}
|
|
||||||
>
|
|
||||||
{versionStatus === "up_to_date"
|
|
||||||
? t("dashboard.upToDate")
|
|
||||||
: t("dashboard.updateAvailable")}
|
|
||||||
</Button>
|
|
||||||
<UpdateLog loggedIn={loggedIn} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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} className="shrink-0" />
|
|
||||||
<p className="ml-2 leading-none truncate">
|
|
||||||
{t("dashboard.uptime")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-row items-center">
|
|
||||||
<p className="leading-none text-muted-foreground">
|
|
||||||
{uptime}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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} className="shrink-0" />
|
|
||||||
<p className="ml-2 leading-none truncate">
|
|
||||||
{t("dashboard.database")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-row items-center">
|
|
||||||
<p
|
|
||||||
className={`leading-none ${dbHealth === "healthy" ? "text-green-400" : "text-red-400"}`}
|
|
||||||
>
|
|
||||||
{dbHealth === "healthy"
|
|
||||||
? t("dashboard.healthy")
|
|
||||||
: t("dashboard.error")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</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-canvas w-full h-auto mt-3 border-2 border-edge rounded-md px-3 py-3 min-w-0 gap-2">
|
|
||||||
<div className="flex flex-row items-center min-w-0">
|
|
||||||
<Server size={16} className="mr-3 shrink-0" />
|
|
||||||
<p className="m-0 leading-none truncate">
|
|
||||||
{t("dashboard.totalServers")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p className="m-0 leading-none text-muted-foreground font-semibold">
|
|
||||||
{totalServers}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-row items-center justify-between bg-canvas w-full h-auto mt-3 border-2 border-edge rounded-md px-3 py-3 min-w-0 gap-2">
|
|
||||||
<div className="flex flex-row items-center min-w-0">
|
|
||||||
<ArrowDownUp size={16} className="mr-3 shrink-0" />
|
|
||||||
<p className="m-0 leading-none truncate">
|
|
||||||
{t("dashboard.totalTunnels")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p className="m-0 leading-none text-muted-foreground font-semibold">
|
|
||||||
{totalTunnels}
|
|
||||||
</p>
|
|
||||||
</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-canvas w-full h-auto mt-3 border-2 border-edge rounded-md px-3 py-3 min-w-0 gap-2">
|
|
||||||
<div className="flex flex-row items-center min-w-0">
|
|
||||||
<Key size={16} className="mr-3 shrink-0" />
|
|
||||||
<p className="m-0 leading-none truncate">
|
|
||||||
{t("dashboard.totalCredentials")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p className="m-0 leading-none text-muted-foreground font-semibold">
|
|
||||||
{totalCredentials}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0 border-2 border-edge rounded-md bg-elevated 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">
|
|
||||||
<Clock className="mr-3" />
|
|
||||||
{t("dashboard.recentActivity")}
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="border-2 !border-edge h-7 !bg-canvas"
|
|
||||||
onClick={handleResetActivity}
|
|
||||||
>
|
|
||||||
{t("dashboard.reset")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`grid gap-4 grid-cols-3 auto-rows-min overflow-x-hidden thin-scrollbar ${recentActivityLoading ? "overflow-y-hidden" : "overflow-y-auto"}`}
|
|
||||||
>
|
|
||||||
{recentActivityLoading ? (
|
|
||||||
<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>
|
|
||||||
) : recentActivity.length === 0 ? (
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
{t("dashboard.noRecentActivity")}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
recentActivity
|
|
||||||
.filter((item, index, array) => {
|
|
||||||
if (index === 0) return true;
|
|
||||||
|
|
||||||
const prevItem = array[index - 1];
|
|
||||||
return !(
|
|
||||||
item.hostId === prevItem.hostId &&
|
|
||||||
item.type === prevItem.type
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.map((item) => (
|
|
||||||
<Button
|
|
||||||
key={item.id}
|
|
||||||
variant="outline"
|
|
||||||
className="border-2 !border-edge !bg-canvas min-w-0"
|
|
||||||
onClick={() => handleActivityClick(item)}
|
|
||||||
>
|
|
||||||
{item.type === "terminal" ? (
|
|
||||||
<Terminal size={20} className="shrink-0" />
|
|
||||||
) : item.type === "file_manager" ? (
|
|
||||||
<FolderOpen size={20} className="shrink-0" />
|
|
||||||
) : item.type === "server_stats" ? (
|
|
||||||
<Server size={20} className="shrink-0" />
|
|
||||||
) : item.type === "tunnel" ? (
|
|
||||||
<ArrowDownUp size={20} className="shrink-0" />
|
|
||||||
) : item.type === "docker" ? (
|
|
||||||
<Container size={20} className="shrink-0" />
|
|
||||||
) : (
|
|
||||||
<Terminal size={20} className="shrink-0" />
|
|
||||||
)}
|
|
||||||
<p className="truncate ml-2 font-semibold">
|
|
||||||
{item.hostName}
|
|
||||||
</p>
|
|
||||||
</Button>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<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-edge rounded-md bg-elevated 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 thin-scrollbar">
|
|
||||||
<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-3 auto-rows-min overflow-y-auto overflow-x-hidden thin-scrollbar">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 !bg-canvas"
|
|
||||||
onClick={handleAddHost}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center w-full max-w-full">
|
|
||||||
<Server
|
|
||||||
className="shrink-0"
|
|
||||||
style={{ width: "40px", height: "40px" }}
|
|
||||||
/>
|
/>
|
||||||
<span
|
);
|
||||||
className="font-semibold text-sm mt-2 text-center block"
|
} else if (card.id === "recent_activity") {
|
||||||
style={{
|
return (
|
||||||
wordWrap: "break-word",
|
<RecentActivityCard
|
||||||
overflowWrap: "break-word",
|
key={card.id}
|
||||||
width: "100%",
|
activities={recentActivity}
|
||||||
maxWidth: "100%",
|
loading={recentActivityLoading}
|
||||||
hyphens: "auto",
|
onReset={handleResetActivity}
|
||||||
display: "block",
|
onActivityClick={handleActivityClick}
|
||||||
whiteSpace: "normal",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("dashboard.addHost")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 !bg-canvas"
|
|
||||||
onClick={handleAddCredential}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center w-full max-w-full">
|
|
||||||
<Key
|
|
||||||
className="shrink-0"
|
|
||||||
style={{ width: "40px", height: "40px" }}
|
|
||||||
/>
|
/>
|
||||||
<span
|
);
|
||||||
className="font-semibold text-sm mt-2 text-center block"
|
} else if (card.id === "network_graph") {
|
||||||
style={{
|
return (
|
||||||
wordWrap: "break-word",
|
<NetworkGraphCard
|
||||||
overflowWrap: "break-word",
|
key={card.id}
|
||||||
width: "100%",
|
isTopbarOpen={isTopbarOpen}
|
||||||
maxWidth: "100%",
|
rightSidebarOpen={rightSidebarOpen}
|
||||||
hyphens: "auto",
|
rightSidebarWidth={rightSidebarWidth}
|
||||||
display: "block",
|
|
||||||
whiteSpace: "normal",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("dashboard.addCredential")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
{isAdmin && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 !bg-canvas"
|
|
||||||
onClick={handleOpenAdminSettings}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center w-full max-w-full">
|
|
||||||
<Settings
|
|
||||||
className="shrink-0"
|
|
||||||
style={{ width: "40px", height: "40px" }}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className="font-semibold text-sm mt-2 text-center block"
|
|
||||||
style={{
|
|
||||||
wordWrap: "break-word",
|
|
||||||
overflowWrap: "break-word",
|
|
||||||
width: "100%",
|
|
||||||
maxWidth: "100%",
|
|
||||||
hyphens: "auto",
|
|
||||||
display: "block",
|
|
||||||
whiteSpace: "normal",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("dashboard.adminSettings")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 !bg-canvas"
|
|
||||||
onClick={handleOpenUserProfile}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center w-full max-w-full">
|
|
||||||
<User
|
|
||||||
className="shrink-0"
|
|
||||||
style={{ width: "40px", height: "40px" }}
|
|
||||||
/>
|
/>
|
||||||
<span
|
);
|
||||||
className="font-semibold text-sm mt-2 text-center block"
|
} else if (card.id === "quick_actions") {
|
||||||
style={{
|
return (
|
||||||
wordWrap: "break-word",
|
<QuickActionsCard
|
||||||
overflowWrap: "break-word",
|
key={card.id}
|
||||||
width: "100%",
|
isAdmin={isAdmin}
|
||||||
maxWidth: "100%",
|
onAddHost={handleAddHost}
|
||||||
hyphens: "auto",
|
onAddCredential={handleAddCredential}
|
||||||
display: "block",
|
onOpenAdminSettings={handleOpenAdminSettings}
|
||||||
whiteSpace: "normal",
|
onOpenUserProfile={handleOpenUserProfile}
|
||||||
}}
|
/>
|
||||||
>
|
);
|
||||||
{t("dashboard.userProfile")}
|
} else if (card.id === "server_stats") {
|
||||||
</span>
|
return (
|
||||||
</div>
|
<ServerStatsCard
|
||||||
</Button>
|
key={card.id}
|
||||||
</div>
|
serverStats={serverStats}
|
||||||
</div>
|
loading={serverStatsLoading}
|
||||||
|
onServerClick={handleServerStatClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0 border-2 border-edge rounded-md bg-elevated 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-3 auto-rows-min overflow-x-hidden thin-scrollbar ${serverStatsLoading ? "overflow-y-hidden" : "overflow-y-auto"}`}
|
|
||||||
>
|
|
||||||
{serverStatsLoading ? (
|
|
||||||
<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>
|
|
||||||
) : serverStats.length === 0 ? (
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
{t("dashboard.noServerData")}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
serverStats.map((server) => (
|
|
||||||
<Button
|
|
||||||
key={server.id}
|
|
||||||
variant="outline"
|
|
||||||
className="border-2 !border-edge bg-canvas h-auto p-3 min-w-0 !bg-canvas"
|
|
||||||
onClick={() =>
|
|
||||||
handleServerStatClick(server.id, server.name)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col w-full">
|
|
||||||
<div className="flex flex-row items-center mb-2">
|
|
||||||
<Server size={20} className="shrink-0" />
|
|
||||||
<p className="truncate ml-2 font-semibold">
|
|
||||||
{server.name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-row justify-start gap-4 text-xs text-muted-foreground">
|
|
||||||
<span>
|
|
||||||
{t("dashboard.cpu")}:{" "}
|
|
||||||
{server.cpu !== null
|
|
||||||
? `${server.cpu}%`
|
|
||||||
: t("dashboard.notAvailable")}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{t("dashboard.ram")}:{" "}
|
|
||||||
{server.ram !== null
|
|
||||||
? `${server.ram}%`
|
|
||||||
: t("dashboard.notAvailable")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AlertManager userId={userId} loggedIn={loggedIn} />
|
<AlertManager userId={userId} loggedIn={loggedIn} />
|
||||||
|
|
||||||
|
{layout && (
|
||||||
|
<DashboardSettingsDialog
|
||||||
|
open={settingsDialogOpen}
|
||||||
|
onOpenChange={setSettingsDialogOpen}
|
||||||
|
currentLayout={layout}
|
||||||
|
onSave={updateLayout}
|
||||||
|
onReset={resetLayout}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button.tsx";
|
|||||||
import { getUserAlerts, dismissAlert } from "@/ui/main-axios.ts";
|
import { getUserAlerts, dismissAlert } from "@/ui/main-axios.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { TermixAlert } from "../../../../../../types";
|
import type { TermixAlert } from "../../../../../../types";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface AlertManagerProps {
|
interface AlertManagerProps {
|
||||||
userId: string | null;
|
userId: string | null;
|
||||||
@@ -53,7 +54,6 @@ export function AlertManager({
|
|||||||
setAlerts(sortedAlerts);
|
setAlerts(sortedAlerts);
|
||||||
setCurrentAlertIndex(0);
|
setCurrentAlertIndex(0);
|
||||||
} catch {
|
} catch {
|
||||||
const { toast } = await import("sonner");
|
|
||||||
toast.error(t("homepage.failedToLoadAlerts"));
|
toast.error(t("homepage.failedToLoadAlerts"));
|
||||||
setError(t("homepage.failedToLoadAlerts"));
|
setError(t("homepage.failedToLoadAlerts"));
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
1261
src/ui/desktop/apps/dashboard/cards/NetworkGraphCard.tsx
Normal file
1261
src/ui/desktop/apps/dashboard/cards/NetworkGraphCard.tsx
Normal file
File diff suppressed because it is too large
Load Diff
141
src/ui/desktop/apps/dashboard/cards/QuickActionsCard.tsx
Normal file
141
src/ui/desktop/apps/dashboard/cards/QuickActionsCard.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { FastForward, Server, Key, Settings, User } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
interface QuickActionsCardProps {
|
||||||
|
isAdmin: boolean;
|
||||||
|
onAddHost: () => void;
|
||||||
|
onAddCredential: () => void;
|
||||||
|
onOpenAdminSettings: () => void;
|
||||||
|
onOpenUserProfile: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuickActionsCard({
|
||||||
|
isAdmin,
|
||||||
|
onAddHost,
|
||||||
|
onAddCredential,
|
||||||
|
onOpenAdminSettings,
|
||||||
|
onOpenUserProfile,
|
||||||
|
}: QuickActionsCardProps): React.ReactElement {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-2 border-edge rounded-md 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 thin-scrollbar">
|
||||||
|
<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-3 auto-rows-min overflow-y-auto overflow-x-hidden thin-scrollbar">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3"
|
||||||
|
onClick={onAddHost}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center w-full max-w-full">
|
||||||
|
<Server
|
||||||
|
className="shrink-0"
|
||||||
|
style={{ width: "40px", height: "40px" }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="font-semibold text-sm mt-2 text-center block"
|
||||||
|
style={{
|
||||||
|
wordWrap: "break-word",
|
||||||
|
overflowWrap: "break-word",
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: "100%",
|
||||||
|
hyphens: "auto",
|
||||||
|
display: "block",
|
||||||
|
whiteSpace: "normal",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("dashboard.addHost")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3"
|
||||||
|
onClick={onAddCredential}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center w-full max-w-full">
|
||||||
|
<Key
|
||||||
|
className="shrink-0"
|
||||||
|
style={{ width: "40px", height: "40px" }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="font-semibold text-sm mt-2 text-center block"
|
||||||
|
style={{
|
||||||
|
wordWrap: "break-word",
|
||||||
|
overflowWrap: "break-word",
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: "100%",
|
||||||
|
hyphens: "auto",
|
||||||
|
display: "block",
|
||||||
|
whiteSpace: "normal",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("dashboard.addCredential")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
{isAdmin && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3"
|
||||||
|
onClick={onOpenAdminSettings}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center w-full max-w-full">
|
||||||
|
<Settings
|
||||||
|
className="shrink-0"
|
||||||
|
style={{ width: "40px", height: "40px" }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="font-semibold text-sm mt-2 text-center block"
|
||||||
|
style={{
|
||||||
|
wordWrap: "break-word",
|
||||||
|
overflowWrap: "break-word",
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: "100%",
|
||||||
|
hyphens: "auto",
|
||||||
|
display: "block",
|
||||||
|
whiteSpace: "normal",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("dashboard.adminSettings")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3"
|
||||||
|
onClick={onOpenUserProfile}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center w-full max-w-full">
|
||||||
|
<User
|
||||||
|
className="shrink-0"
|
||||||
|
style={{ width: "40px", height: "40px" }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="font-semibold text-sm mt-2 text-center block"
|
||||||
|
style={{
|
||||||
|
wordWrap: "break-word",
|
||||||
|
overflowWrap: "break-word",
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: "100%",
|
||||||
|
hyphens: "auto",
|
||||||
|
display: "block",
|
||||||
|
whiteSpace: "normal",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("dashboard.userProfile")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
src/ui/desktop/apps/dashboard/cards/RecentActivityCard.tsx
Normal file
97
src/ui/desktop/apps/dashboard/cards/RecentActivityCard.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Clock,
|
||||||
|
Loader2,
|
||||||
|
Terminal,
|
||||||
|
FolderOpen,
|
||||||
|
Server,
|
||||||
|
ArrowDownUp,
|
||||||
|
Container,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { type RecentActivityItem } from "@/ui/main-axios";
|
||||||
|
|
||||||
|
interface RecentActivityCardProps {
|
||||||
|
activities: RecentActivityItem[];
|
||||||
|
loading: boolean;
|
||||||
|
onReset: () => void;
|
||||||
|
onActivityClick: (item: RecentActivityItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecentActivityCard({
|
||||||
|
activities,
|
||||||
|
loading,
|
||||||
|
onReset,
|
||||||
|
onActivityClick,
|
||||||
|
}: RecentActivityCardProps): React.ReactElement {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-2 border-edge rounded-md 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">
|
||||||
|
<Clock className="mr-3" />
|
||||||
|
{t("dashboard.recentActivity")}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="border-2 !border-edge h-7 !bg-canvas"
|
||||||
|
onClick={onReset}
|
||||||
|
>
|
||||||
|
{t("dashboard.reset")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`grid gap-4 grid-cols-3 auto-rows-min overflow-x-hidden thin-scrollbar ${loading ? "overflow-y-hidden" : "overflow-y-auto"}`}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<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>
|
||||||
|
) : activities.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{t("dashboard.noRecentActivity")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
activities
|
||||||
|
.filter((item, index, array) => {
|
||||||
|
if (index === 0) return true;
|
||||||
|
|
||||||
|
const prevItem = array[index - 1];
|
||||||
|
return !(
|
||||||
|
item.hostId === prevItem.hostId && item.type === prevItem.type
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map((item) => (
|
||||||
|
<Button
|
||||||
|
key={item.id}
|
||||||
|
variant="outline"
|
||||||
|
className="border-2 !border-edge min-w-0"
|
||||||
|
onClick={() => onActivityClick(item)}
|
||||||
|
>
|
||||||
|
{item.type === "terminal" ? (
|
||||||
|
<Terminal size={20} className="shrink-0" />
|
||||||
|
) : item.type === "file_manager" ? (
|
||||||
|
<FolderOpen size={20} className="shrink-0" />
|
||||||
|
) : item.type === "server_stats" ? (
|
||||||
|
<Server size={20} className="shrink-0" />
|
||||||
|
) : item.type === "tunnel" ? (
|
||||||
|
<ArrowDownUp size={20} className="shrink-0" />
|
||||||
|
) : item.type === "docker" ? (
|
||||||
|
<Container size={20} className="shrink-0" />
|
||||||
|
) : (
|
||||||
|
<Terminal size={20} className="shrink-0" />
|
||||||
|
)}
|
||||||
|
<p className="truncate ml-2 font-semibold">{item.hostName}</p>
|
||||||
|
</Button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
142
src/ui/desktop/apps/dashboard/cards/ServerOverviewCard.tsx
Normal file
142
src/ui/desktop/apps/dashboard/cards/ServerOverviewCard.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Server,
|
||||||
|
History,
|
||||||
|
Clock,
|
||||||
|
Database,
|
||||||
|
Key,
|
||||||
|
ArrowDownUp,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { UpdateLog } from "@/ui/desktop/apps/dashboard/apps/UpdateLog";
|
||||||
|
|
||||||
|
interface ServerOverviewCardProps {
|
||||||
|
loggedIn: boolean;
|
||||||
|
versionText: string;
|
||||||
|
versionStatus: "up_to_date" | "requires_update";
|
||||||
|
uptime: string;
|
||||||
|
dbHealth: "healthy" | "error";
|
||||||
|
totalServers: number;
|
||||||
|
totalTunnels: number;
|
||||||
|
totalCredentials: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServerOverviewCard({
|
||||||
|
loggedIn,
|
||||||
|
versionText,
|
||||||
|
versionStatus,
|
||||||
|
uptime,
|
||||||
|
dbHealth,
|
||||||
|
totalServers,
|
||||||
|
totalTunnels,
|
||||||
|
totalCredentials,
|
||||||
|
}: ServerOverviewCardProps): React.ReactElement {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-2 border-edge rounded-md 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 thin-scrollbar">
|
||||||
|
<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="w-full h-auto border-2 border-edge rounded-md px-3 py-3">
|
||||||
|
<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} className="shrink-0" />
|
||||||
|
<p className="ml-2 leading-none truncate">
|
||||||
|
{t("dashboard.version")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
<p className="leading-none text-muted-foreground">
|
||||||
|
{versionText}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className={`ml-2 text-sm border-1 border-edge ${versionStatus === "up_to_date" ? "text-green-400" : "text-yellow-400"}`}
|
||||||
|
>
|
||||||
|
{versionStatus === "up_to_date"
|
||||||
|
? t("dashboard.upToDate")
|
||||||
|
: t("dashboard.updateAvailable")}
|
||||||
|
</Button>
|
||||||
|
<UpdateLog loggedIn={loggedIn} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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} className="shrink-0" />
|
||||||
|
<p className="ml-2 leading-none truncate">
|
||||||
|
{t("dashboard.uptime")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
<p className="leading-none text-muted-foreground">{uptime}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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} className="shrink-0" />
|
||||||
|
<p className="ml-2 leading-none truncate">
|
||||||
|
{t("dashboard.database")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
<p
|
||||||
|
className={`leading-none ${dbHealth === "healthy" ? "text-green-400" : "text-red-400"}`}
|
||||||
|
>
|
||||||
|
{dbHealth === "healthy"
|
||||||
|
? t("dashboard.healthy")
|
||||||
|
: t("dashboard.error")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col grid grid-cols-2 gap-2 mt-2">
|
||||||
|
<div className="flex flex-row items-center justify-between w-full h-auto mt-3 border-2 border-edge rounded-md px-3 py-3 min-w-0 gap-2">
|
||||||
|
<div className="flex flex-row items-center min-w-0">
|
||||||
|
<Server size={16} className="mr-3 shrink-0" />
|
||||||
|
<p className="m-0 leading-none truncate">
|
||||||
|
{t("dashboard.totalServers")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="m-0 leading-none text-muted-foreground font-semibold">
|
||||||
|
{totalServers}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row items-center justify-between w-full h-auto mt-3 border-2 border-edge rounded-md px-3 py-3 min-w-0 gap-2">
|
||||||
|
<div className="flex flex-row items-center min-w-0">
|
||||||
|
<ArrowDownUp size={16} className="mr-3 shrink-0" />
|
||||||
|
<p className="m-0 leading-none truncate">
|
||||||
|
{t("dashboard.totalTunnels")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="m-0 leading-none text-muted-foreground font-semibold">
|
||||||
|
{totalTunnels}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col grid grid-cols-2 gap-2 mt-2">
|
||||||
|
<div className="flex flex-row items-center justify-between w-full h-auto mt-3 border-2 border-edge rounded-md px-3 py-3 min-w-0 gap-2">
|
||||||
|
<div className="flex flex-row items-center min-w-0">
|
||||||
|
<Key size={16} className="mr-3 shrink-0" />
|
||||||
|
<p className="m-0 leading-none truncate">
|
||||||
|
{t("dashboard.totalCredentials")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="m-0 leading-none text-muted-foreground font-semibold">
|
||||||
|
{totalCredentials}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
src/ui/desktop/apps/dashboard/cards/ServerStatsCard.tsx
Normal file
80
src/ui/desktop/apps/dashboard/cards/ServerStatsCard.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ChartLine, Loader2, Server } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
interface ServerStat {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
cpu: number | null;
|
||||||
|
ram: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServerStatsCardProps {
|
||||||
|
serverStats: ServerStat[];
|
||||||
|
loading: boolean;
|
||||||
|
onServerClick: (serverId: number, serverName: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServerStatsCard({
|
||||||
|
serverStats,
|
||||||
|
loading,
|
||||||
|
onServerClick,
|
||||||
|
}: ServerStatsCardProps): React.ReactElement {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-2 border-edge rounded-md 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-3 auto-rows-min overflow-x-hidden thin-scrollbar ${loading ? "overflow-y-hidden" : "overflow-y-auto"}`}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<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>
|
||||||
|
) : serverStats.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{t("dashboard.noServerData")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
serverStats.map((server) => (
|
||||||
|
<Button
|
||||||
|
key={server.id}
|
||||||
|
variant="outline"
|
||||||
|
className="border-2 !border-edge h-auto p-3 min-w-0"
|
||||||
|
onClick={() => onServerClick(server.id, server.name)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col w-full">
|
||||||
|
<div className="flex flex-row items-center mb-2">
|
||||||
|
<Server size={20} className="shrink-0" />
|
||||||
|
<p className="truncate ml-2 font-semibold">{server.name}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row justify-start gap-4 text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{t("dashboard.cpu")}:{" "}
|
||||||
|
{server.cpu !== null
|
||||||
|
? `${server.cpu}%`
|
||||||
|
: t("dashboard.notAvailable")}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{t("dashboard.ram")}:{" "}
|
||||||
|
{server.ram !== null
|
||||||
|
? `${server.ram}%`
|
||||||
|
: t("dashboard.notAvailable")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { DashboardLayout } from "@/ui/main-axios";
|
||||||
|
|
||||||
|
interface DashboardSettingsDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
currentLayout: DashboardLayout;
|
||||||
|
onSave: (layout: DashboardLayout) => void;
|
||||||
|
onReset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardSettingsDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
currentLayout,
|
||||||
|
onSave,
|
||||||
|
onReset,
|
||||||
|
}: DashboardSettingsDialogProps): React.ReactElement {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [layout, setLayout] = useState<DashboardLayout>(currentLayout);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLayout(currentLayout);
|
||||||
|
}, [currentLayout, open]);
|
||||||
|
|
||||||
|
const handleCardToggle = (cardId: string, enabled: boolean) => {
|
||||||
|
setLayout((prev) => ({
|
||||||
|
...prev,
|
||||||
|
cards: prev.cards.map((card) =>
|
||||||
|
card.id === cardId ? { ...card, enabled } : card,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGridColumnsChange = (value: string) => {
|
||||||
|
setLayout((prev) => ({
|
||||||
|
...prev,
|
||||||
|
gridColumns: parseInt(value, 10),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
onSave(layout);
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
onReset();
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cardLabels: Record<string, string> = {
|
||||||
|
server_overview: t("dashboard.serverOverviewCard"),
|
||||||
|
recent_activity: t("dashboard.recentActivityCard"),
|
||||||
|
network_graph: t("dashboard.networkGraphCard"),
|
||||||
|
quick_actions: t("dashboard.quickActionsCard"),
|
||||||
|
server_stats: t("dashboard.serverStatsCard"),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[500px] bg-canvas border-2 border-edge">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("dashboard.dashboardSettings")}</DialogTitle>
|
||||||
|
<DialogDescription className="text-muted-foreground">
|
||||||
|
{t("dashboard.customizeLayout")}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-6 py-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-base font-semibold">
|
||||||
|
{t("dashboard.enableDisableCards")}
|
||||||
|
</Label>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{layout.cards.map((card) => (
|
||||||
|
<div
|
||||||
|
key={card.id}
|
||||||
|
className="flex items-center space-x-3 border-2 border-edge rounded-md p-3"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id={card.id}
|
||||||
|
checked={card.enabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleCardToggle(card.id, checked === true)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor={card.id}
|
||||||
|
className="text-sm font-normal cursor-pointer flex-1"
|
||||||
|
>
|
||||||
|
{cardLabels[card.id] || card.id}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-base font-semibold">
|
||||||
|
{t("dashboard.gridColumns")}
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={layout.gridColumns.toString()}
|
||||||
|
onValueChange={handleGridColumnsChange}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full border-2 border-edge">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1">
|
||||||
|
1 {t("dashboard.column", { count: 1 })}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="2">
|
||||||
|
2 {t("dashboard.columns", { count: 2 })}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="3">
|
||||||
|
3 {t("dashboard.columns", { count: 3 })}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="flex-row gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleReset}
|
||||||
|
className="border-2 border-edge"
|
||||||
|
>
|
||||||
|
{t("dashboard.resetLayout")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
className="border-2 border-edge"
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave}>{t("common.save")}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
getDashboardPreferences,
|
||||||
|
saveDashboardPreferences,
|
||||||
|
type DashboardLayout,
|
||||||
|
} from "@/ui/main-axios";
|
||||||
|
|
||||||
|
const DEFAULT_LAYOUT: DashboardLayout = {
|
||||||
|
cards: [
|
||||||
|
{ id: "server_overview", enabled: true, order: 1 },
|
||||||
|
{ id: "recent_activity", enabled: true, order: 2 },
|
||||||
|
{ id: "network_graph", enabled: false, order: 3 },
|
||||||
|
{ id: "quick_actions", enabled: true, order: 4 },
|
||||||
|
{ id: "server_stats", enabled: true, order: 5 },
|
||||||
|
],
|
||||||
|
gridColumns: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useDashboardPreferences() {
|
||||||
|
const [layout, setLayout] = useState<DashboardLayout | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saveTimeout, setSaveTimeout] = useState<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchPreferences = async () => {
|
||||||
|
try {
|
||||||
|
const preferences = await getDashboardPreferences();
|
||||||
|
setLayout(preferences);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load dashboard preferences:", error);
|
||||||
|
setLayout(DEFAULT_LAYOUT);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchPreferences();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateLayout = useCallback(
|
||||||
|
(newLayout: DashboardLayout) => {
|
||||||
|
setLayout(newLayout);
|
||||||
|
|
||||||
|
if (saveTimeout) {
|
||||||
|
clearTimeout(saveTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await saveDashboardPreferences(newLayout);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save dashboard preferences:", error);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
setSaveTimeout(timeout);
|
||||||
|
},
|
||||||
|
[saveTimeout],
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetLayout = useCallback(async () => {
|
||||||
|
setLayout(DEFAULT_LAYOUT);
|
||||||
|
try {
|
||||||
|
await saveDashboardPreferences(DEFAULT_LAYOUT);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to reset dashboard preferences:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
layout,
|
||||||
|
loading,
|
||||||
|
updateLayout,
|
||||||
|
resetLayout,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
keepaliveDockerSession,
|
keepaliveDockerSession,
|
||||||
verifyDockerTOTP,
|
verifyDockerTOTP,
|
||||||
logActivity,
|
logActivity,
|
||||||
|
getSSHHosts,
|
||||||
} from "@/ui/main-axios.ts";
|
} from "@/ui/main-axios.ts";
|
||||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||||
import { AlertCircle } from "lucide-react";
|
import { AlertCircle } from "lucide-react";
|
||||||
@@ -121,7 +122,6 @@ export function DockerManager({
|
|||||||
const fetchLatestHostConfig = async () => {
|
const fetchLatestHostConfig = async () => {
|
||||||
if (hostConfig?.id) {
|
if (hostConfig?.id) {
|
||||||
try {
|
try {
|
||||||
const { getSSHHosts } = await import("@/ui/main-axios.ts");
|
|
||||||
const hosts = await getSSHHosts();
|
const hosts = await getSSHHosts();
|
||||||
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
|
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
|
||||||
if (updatedHost) {
|
if (updatedHost) {
|
||||||
@@ -138,7 +138,6 @@ export function DockerManager({
|
|||||||
const handleHostsChanged = async () => {
|
const handleHostsChanged = async () => {
|
||||||
if (hostConfig?.id) {
|
if (hostConfig?.id) {
|
||||||
try {
|
try {
|
||||||
const { getSSHHosts } = await import("@/ui/main-axios.ts");
|
|
||||||
const hosts = await getSSHHosts();
|
const hosts = await getSSHHosts();
|
||||||
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
|
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
|
||||||
if (updatedHost) {
|
if (updatedHost) {
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ export function ContainerCard({
|
|||||||
>
|
>
|
||||||
<CardHeader className="pb-2 px-4">
|
<CardHeader className="pb-2 px-4">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<CardTitle className="text-base font-semibold truncate flex-1">
|
<CardTitle className="text-base font-semibold truncate flex-1 min-w-0">
|
||||||
{container.name.startsWith("/")
|
{container.name.startsWith("/")
|
||||||
? container.name.slice(1)
|
? container.name.slice(1)
|
||||||
: container.name}
|
: container.name}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx";
|
|||||||
import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx";
|
import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx";
|
||||||
import { PermissionsDialog } from "./components/PermissionsDialog.tsx";
|
import { PermissionsDialog } from "./components/PermissionsDialog.tsx";
|
||||||
import { CompressDialog } from "./components/CompressDialog.tsx";
|
import { CompressDialog } from "./components/CompressDialog.tsx";
|
||||||
|
import { SudoPasswordDialog } from "./SudoPasswordDialog.tsx";
|
||||||
import {
|
import {
|
||||||
Upload,
|
Upload,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
@@ -57,6 +58,7 @@ import {
|
|||||||
changeSSHPermissions,
|
changeSSHPermissions,
|
||||||
extractSSHArchive,
|
extractSSHArchive,
|
||||||
compressSSHFiles,
|
compressSSHFiles,
|
||||||
|
setSudoPassword,
|
||||||
} from "@/ui/main-axios.ts";
|
} from "@/ui/main-axios.ts";
|
||||||
import type { SidebarItem } from "./FileManagerSidebar.tsx";
|
import type { SidebarItem } from "./FileManagerSidebar.tsx";
|
||||||
|
|
||||||
@@ -163,6 +165,13 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [sudoDialogOpen, setSudoDialogOpen] = useState(false);
|
||||||
|
const [pendingSudoOperation, setPendingSudoOperation] = useState<
|
||||||
|
| { type: "delete"; files: FileItem[] }
|
||||||
|
| { type: "navigate"; path: string }
|
||||||
|
| null
|
||||||
|
>(null);
|
||||||
|
|
||||||
const { selectedFiles, clearSelection, setSelection } = useFileSelection();
|
const { selectedFiles, clearSelection, setSelection } = useFileSelection();
|
||||||
|
|
||||||
const { dragHandlers } = useDragAndDrop({
|
const { dragHandlers } = useDragAndDrop({
|
||||||
@@ -392,14 +401,14 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadDirectory = useCallback(
|
const loadDirectory = useCallback(
|
||||||
async (path: string) => {
|
async (path: string): Promise<boolean> => {
|
||||||
if (!sshSessionId) {
|
if (!sshSessionId) {
|
||||||
console.error("Cannot load directory: no SSH session ID");
|
console.error("Cannot load directory: no SSH session ID");
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading && currentLoadingPathRef.current !== path) {
|
if (isLoading && currentLoadingPathRef.current !== path) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
currentLoadingPathRef.current = path;
|
currentLoadingPathRef.current = path;
|
||||||
@@ -411,7 +420,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
const response = await listSSHFiles(sshSessionId, path);
|
const response = await listSSHFiles(sshSessionId, path);
|
||||||
|
|
||||||
if (currentLoadingPathRef.current !== path) {
|
if (currentLoadingPathRef.current !== path) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = Array.isArray(response)
|
const files = Array.isArray(response)
|
||||||
@@ -420,29 +429,63 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
|
|
||||||
setFiles(files);
|
setFiles(files);
|
||||||
clearSelection();
|
clearSelection();
|
||||||
|
return true;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (currentLoadingPathRef.current === path) {
|
if (currentLoadingPathRef.current === path) {
|
||||||
|
const axiosError = error as {
|
||||||
|
response?: {
|
||||||
|
status?: number;
|
||||||
|
data?: {
|
||||||
|
needsSudo?: boolean;
|
||||||
|
error?: string;
|
||||||
|
sudoFailed?: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if this is a permission denied error that needs sudo
|
||||||
|
if (axiosError.response?.data?.needsSudo) {
|
||||||
|
console.log("Permission denied, sudo required for:", path);
|
||||||
|
|
||||||
|
// Only show dialog if not already in a sudo retry flow
|
||||||
|
if (!sudoDialogOpen) {
|
||||||
|
setPendingSudoOperation({ type: "navigate", path });
|
||||||
|
setSudoDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (axiosError.response.data.sudoFailed) {
|
||||||
|
toast.error(t("fileManager.sudoAuthFailed"));
|
||||||
|
} else {
|
||||||
|
toast.error(t("fileManager.permissionDenied"));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
console.error("Failed to load directory:", error);
|
console.error("Failed to load directory:", error);
|
||||||
|
|
||||||
|
// Show more specific error message
|
||||||
|
const errorMessage =
|
||||||
|
axiosError.response?.data?.error ||
|
||||||
|
axiosError.message ||
|
||||||
|
String(error);
|
||||||
|
|
||||||
if (initialLoadDoneRef.current) {
|
if (initialLoadDoneRef.current) {
|
||||||
toast.error(
|
toast.error(
|
||||||
t("fileManager.failedToLoadDirectory") +
|
t("fileManager.failedToLoadDirectory") + ": " + errorMessage,
|
||||||
": " +
|
|
||||||
(error.message || error),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
error.message?.includes("connection") ||
|
errorMessage?.includes("connection") ||
|
||||||
error.message?.includes("SSH")
|
errorMessage?.includes("SSH")
|
||||||
) {
|
) {
|
||||||
handleCloseWithError(
|
handleCloseWithError(
|
||||||
t("fileManager.failedToLoadDirectory") +
|
t("fileManager.failedToLoadDirectory") + ": " + errorMessage,
|
||||||
": " +
|
|
||||||
(error.message || error),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
if (currentLoadingPathRef.current === path) {
|
if (currentLoadingPathRef.current === path) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -450,7 +493,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[sshSessionId, isLoading, clearSelection, t],
|
[sshSessionId, isLoading, clearSelection, t, sudoDialogOpen],
|
||||||
);
|
);
|
||||||
|
|
||||||
const debouncedLoadDirectory = useCallback(
|
const debouncedLoadDirectory = useCallback(
|
||||||
@@ -720,9 +763,18 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
handleRefreshDirectory();
|
handleRefreshDirectory();
|
||||||
clearSelection();
|
clearSelection();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
const axiosError = error as {
|
||||||
|
response?: { data?: { needsSudo?: boolean; error?: string } };
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
if (axiosError.response?.data?.needsSudo) {
|
||||||
|
setPendingSudoOperation({ type: "delete", files });
|
||||||
|
setSudoDialogOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
error.message?.includes("connection") ||
|
axiosError.message?.includes("connection") ||
|
||||||
error.message?.includes("established")
|
axiosError.message?.includes("established")
|
||||||
) {
|
) {
|
||||||
toast.error(
|
toast.error(
|
||||||
`SSH connection failed. Please check your connection to ${currentHost?.name} (${currentHost?.ip}:${currentHost?.port})`,
|
`SSH connection failed. Please check your connection to ${currentHost?.name} (${currentHost?.ip}:${currentHost?.port})`,
|
||||||
@@ -737,6 +789,60 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSudoPasswordSubmit(password: string) {
|
||||||
|
if (!sshSessionId || !pendingSudoOperation) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await setSudoPassword(sshSessionId, password);
|
||||||
|
setSudoDialogOpen(false);
|
||||||
|
|
||||||
|
if (pendingSudoOperation.type === "delete") {
|
||||||
|
for (const file of pendingSudoOperation.files) {
|
||||||
|
await deleteSSHItem(
|
||||||
|
sshSessionId,
|
||||||
|
file.path,
|
||||||
|
file.type === "directory",
|
||||||
|
currentHost?.id,
|
||||||
|
currentHost?.userId?.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
toast.success(
|
||||||
|
t("fileManager.itemsDeletedSuccessfully", {
|
||||||
|
count: pendingSudoOperation.files.length,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
handleRefreshDirectory();
|
||||||
|
clearSelection();
|
||||||
|
} else if (pendingSudoOperation.type === "navigate") {
|
||||||
|
// Retry navigation with sudo password now set
|
||||||
|
const success = await loadDirectory(pendingSudoOperation.path);
|
||||||
|
if (success) {
|
||||||
|
setCurrentPath(pendingSudoOperation.path);
|
||||||
|
setPendingSudoOperation(null);
|
||||||
|
}
|
||||||
|
// If failed, loadDirectory already handles showing the error/dialog
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingSudoOperation(null);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const axiosError = error as {
|
||||||
|
response?: { data?: { needsSudo?: boolean; sudoFailed?: boolean } };
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// If sudo auth failed, keep dialog open for retry
|
||||||
|
if (axiosError.response?.data?.sudoFailed) {
|
||||||
|
toast.error(t("fileManager.sudoAuthFailed"));
|
||||||
|
setSudoDialogOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.error(axiosError.message || t("fileManager.sudoOperationFailed"));
|
||||||
|
setPendingSudoOperation(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleCreateNewFolder() {
|
function handleCreateNewFolder() {
|
||||||
const defaultName = generateUniqueName(
|
const defaultName = generateUniqueName(
|
||||||
t("fileManager.newFolderDefault"),
|
t("fileManager.newFolderDefault"),
|
||||||
@@ -2173,6 +2279,15 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
}}
|
}}
|
||||||
onSave={handleSavePermissions}
|
onSave={handleSavePermissions}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SudoPasswordDialog
|
||||||
|
open={sudoDialogOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setSudoDialogOpen(open);
|
||||||
|
if (!open) setPendingSudoOperation(null);
|
||||||
|
}}
|
||||||
|
onSubmit={handleSudoPasswordSubmit}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog.tsx";
|
||||||
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
|
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ShieldAlert } from "lucide-react";
|
||||||
|
|
||||||
|
interface SudoPasswordDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSubmit: (password: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SudoPasswordDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSubmit,
|
||||||
|
}: SudoPasswordDialogProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setPassword("");
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e?: React.FormEvent) => {
|
||||||
|
if (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
onSubmit(password);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[500px] bg-canvas border-2 border-edge">
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("fileManager.sudoPasswordRequired")}</DialogTitle>
|
||||||
|
<DialogDescription className="text-muted-foreground">
|
||||||
|
{t("fileManager.enterSudoPassword")}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-6 py-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<PasswordInput
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder={t("fileManager.sudoPassword")}
|
||||||
|
autoFocus
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={!password.trim() || loading}>
|
||||||
|
{loading ? t("common.loading") : t("common.confirm")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -332,17 +332,21 @@ export function FileViewer({
|
|||||||
const getImageDataUrl = (content: string, fileName: string): string => {
|
const getImageDataUrl = (content: string, fileName: string): string => {
|
||||||
const ext = fileName.split(".").pop()?.toLowerCase() || "";
|
const ext = fileName.split(".").pop()?.toLowerCase() || "";
|
||||||
|
|
||||||
if (ext === "svg") {
|
const mimeTypes: Record<string, string> = {
|
||||||
try {
|
svg: "image/svg+xml",
|
||||||
const base64 = btoa(unescape(encodeURIComponent(content)));
|
png: "image/png",
|
||||||
return `data:image/svg+xml;base64,${base64}`;
|
jpg: "image/jpeg",
|
||||||
} catch (e) {
|
jpeg: "image/jpeg",
|
||||||
console.error("Failed to encode SVG:", e);
|
gif: "image/gif",
|
||||||
return "";
|
webp: "image/webp",
|
||||||
}
|
bmp: "image/bmp",
|
||||||
}
|
ico: "image/x-icon",
|
||||||
|
tiff: "image/tiff",
|
||||||
|
tif: "image/tiff",
|
||||||
|
};
|
||||||
|
|
||||||
return `data:image/*;base64,${content}`;
|
const mimeType = mimeTypes[ext] || "image/png";
|
||||||
|
return `data:${mimeType};base64,${content}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const WARNING_SIZE = 50 * 1024 * 1024;
|
const WARNING_SIZE = 50 * 1024 * 1024;
|
||||||
|
|||||||
@@ -47,6 +47,22 @@ interface FileWindowProps {
|
|||||||
onFileNotFound?: (file: FileItem) => void;
|
onFileNotFound?: (file: FileItem) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDisplayableText(str: string): boolean {
|
||||||
|
let printable = 0;
|
||||||
|
for (let i = 0; i < Math.min(str.length, 1000); i++) {
|
||||||
|
const code = str.charCodeAt(i);
|
||||||
|
if (
|
||||||
|
(code >= 32 && code <= 126) ||
|
||||||
|
code === 9 ||
|
||||||
|
code === 10 ||
|
||||||
|
code === 13
|
||||||
|
) {
|
||||||
|
printable++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return printable / Math.min(str.length, 1000) > 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
export function FileWindow({
|
export function FileWindow({
|
||||||
windowId,
|
windowId,
|
||||||
file,
|
file,
|
||||||
@@ -106,7 +122,19 @@ export function FileWindow({
|
|||||||
await ensureSSHConnection();
|
await ensureSSHConnection();
|
||||||
|
|
||||||
const response = await readSSHFile(sshSessionId, file.path);
|
const response = await readSSHFile(sshSessionId, file.path);
|
||||||
const fileContent = response.content || "";
|
let fileContent = response.content || "";
|
||||||
|
|
||||||
|
if (response.encoding === "base64") {
|
||||||
|
try {
|
||||||
|
const decoded = atob(fileContent);
|
||||||
|
if (isDisplayableText(decoded)) {
|
||||||
|
fileContent = decoded;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to decode base64 content:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setContent(fileContent);
|
setContent(fileContent);
|
||||||
setPendingContent(fileContent);
|
setPendingContent(fileContent);
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
submitMetricsTOTP,
|
submitMetricsTOTP,
|
||||||
executeSnippet,
|
executeSnippet,
|
||||||
logActivity,
|
logActivity,
|
||||||
|
sendMetricsHeartbeat,
|
||||||
|
getSSHHosts,
|
||||||
type ServerMetrics,
|
type ServerMetrics,
|
||||||
} from "@/ui/main-axios.ts";
|
} from "@/ui/main-axios.ts";
|
||||||
import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx";
|
import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx";
|
||||||
@@ -31,6 +33,8 @@ import {
|
|||||||
ProcessesWidget,
|
ProcessesWidget,
|
||||||
SystemWidget,
|
SystemWidget,
|
||||||
LoginStatsWidget,
|
LoginStatsWidget,
|
||||||
|
PortsWidget,
|
||||||
|
FirewallWidget,
|
||||||
} from "./widgets";
|
} from "./widgets";
|
||||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||||
|
|
||||||
@@ -145,7 +149,6 @@ export function ServerStats({
|
|||||||
|
|
||||||
const heartbeatInterval = setInterval(async () => {
|
const heartbeatInterval = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const { sendMetricsHeartbeat } = await import("@/ui/main-axios.ts");
|
|
||||||
await sendMetricsHeartbeat(viewerSessionId);
|
await sendMetricsHeartbeat(viewerSessionId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to send heartbeat:", error);
|
console.error("Failed to send heartbeat:", error);
|
||||||
@@ -264,6 +267,16 @@ export function ServerStats({
|
|||||||
<LoginStatsWidget metrics={metrics} metricsHistory={metricsHistory} />
|
<LoginStatsWidget metrics={metrics} metricsHistory={metricsHistory} />
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case "ports":
|
||||||
|
return (
|
||||||
|
<PortsWidget metrics={metrics} metricsHistory={metricsHistory} />
|
||||||
|
);
|
||||||
|
|
||||||
|
case "firewall":
|
||||||
|
return (
|
||||||
|
<FirewallWidget metrics={metrics} metricsHistory={metricsHistory} />
|
||||||
|
);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -273,7 +286,6 @@ export function ServerStats({
|
|||||||
const fetchLatestHostConfig = async () => {
|
const fetchLatestHostConfig = async () => {
|
||||||
if (hostConfig?.id) {
|
if (hostConfig?.id) {
|
||||||
try {
|
try {
|
||||||
const { getSSHHosts } = await import("@/ui/main-axios.ts");
|
|
||||||
const hosts = await getSSHHosts();
|
const hosts = await getSSHHosts();
|
||||||
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
|
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
|
||||||
if (updatedHost) {
|
if (updatedHost) {
|
||||||
@@ -290,7 +302,6 @@ export function ServerStats({
|
|||||||
const handleHostsChanged = async () => {
|
const handleHostsChanged = async () => {
|
||||||
if (hostConfig?.id) {
|
if (hostConfig?.id) {
|
||||||
try {
|
try {
|
||||||
const { getSSHHosts } = await import("@/ui/main-axios.ts");
|
|
||||||
const hosts = await getSSHHosts();
|
const hosts = await getSSHHosts();
|
||||||
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
|
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
|
||||||
if (updatedHost) {
|
if (updatedHost) {
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Shield, ShieldOff, ShieldCheck, ChevronDown } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { ServerMetrics } from "@/ui/main-axios.ts";
|
||||||
|
import type {
|
||||||
|
FirewallMetrics,
|
||||||
|
FirewallChain,
|
||||||
|
FirewallRule,
|
||||||
|
} from "@/types/stats-widgets";
|
||||||
|
|
||||||
|
interface FirewallWidgetProps {
|
||||||
|
metrics: ServerMetrics | null;
|
||||||
|
metricsHistory: ServerMetrics[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function RuleRow({ rule }: { rule: FirewallRule }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const getTargetStyle = (target: string) => {
|
||||||
|
switch (target.toUpperCase()) {
|
||||||
|
case "ACCEPT":
|
||||||
|
return "text-green-400";
|
||||||
|
case "DROP":
|
||||||
|
return "text-red-400";
|
||||||
|
case "REJECT":
|
||||||
|
return "text-orange-400";
|
||||||
|
default:
|
||||||
|
return "text-muted-foreground";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTargetLabel = (target: string) => {
|
||||||
|
switch (target.toUpperCase()) {
|
||||||
|
case "ACCEPT":
|
||||||
|
return t("serverStats.firewall.accept");
|
||||||
|
case "DROP":
|
||||||
|
return t("serverStats.firewall.drop");
|
||||||
|
case "REJECT":
|
||||||
|
return t("serverStats.firewall.reject");
|
||||||
|
default:
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatSource = () => {
|
||||||
|
if (rule.interface) {
|
||||||
|
return rule.interface;
|
||||||
|
}
|
||||||
|
if (rule.state) {
|
||||||
|
return rule.state;
|
||||||
|
}
|
||||||
|
if (rule.source === "0.0.0.0/0") {
|
||||||
|
return t("serverStats.firewall.anywhere");
|
||||||
|
}
|
||||||
|
return rule.source;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-4 gap-2 text-xs py-1.5 border-b border-edge/30 last:border-0">
|
||||||
|
<div className={`font-medium ${getTargetStyle(rule.target)}`}>
|
||||||
|
{getTargetLabel(rule.target)}
|
||||||
|
</div>
|
||||||
|
<div className="text-foreground-subtle font-mono">
|
||||||
|
{rule.protocol.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="text-foreground-subtle font-mono">
|
||||||
|
{rule.dport || "-"}
|
||||||
|
</div>
|
||||||
|
<div className="text-foreground-subtle truncate" title={formatSource()}>
|
||||||
|
{formatSource()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChainSection({ chain }: { chain: FirewallChain }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isOpen, setIsOpen] = React.useState(true);
|
||||||
|
|
||||||
|
const getPolicyStyle = (policy: string) => {
|
||||||
|
switch (policy.toUpperCase()) {
|
||||||
|
case "ACCEPT":
|
||||||
|
return "text-green-400";
|
||||||
|
case "DROP":
|
||||||
|
return "text-red-400";
|
||||||
|
case "REJECT":
|
||||||
|
return "text-orange-400";
|
||||||
|
default:
|
||||||
|
return "text-muted-foreground";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="flex items-center gap-2 w-full py-1.5 hover:bg-canvas/30 rounded px-1 -mx-1 text-left"
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
className={`h-3 w-3 text-muted-foreground transition-transform ${
|
||||||
|
isOpen ? "" : "-rotate-90"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
{chain.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
({t("serverStats.firewall.policy")}:{" "}
|
||||||
|
<span className={getPolicyStyle(chain.policy)}>{chain.policy}</span>)
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground ml-auto">
|
||||||
|
{chain.rules.length} {t("serverStats.firewall.rules")}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{isOpen && (
|
||||||
|
<>
|
||||||
|
{chain.rules.length > 0 ? (
|
||||||
|
<div className="mt-2 ml-5">
|
||||||
|
<div className="grid grid-cols-4 gap-2 text-xs text-muted-foreground border-b border-edge/50 pb-1 mb-1">
|
||||||
|
<div>{t("serverStats.firewall.action")}</div>
|
||||||
|
<div>{t("serverStats.firewall.protocol")}</div>
|
||||||
|
<div>{t("serverStats.firewall.port")}</div>
|
||||||
|
<div>{t("serverStats.firewall.source")}</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-32 overflow-y-auto thin-scrollbar">
|
||||||
|
{chain.rules.map((rule, idx) => (
|
||||||
|
<RuleRow key={idx} rule={rule} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-muted-foreground ml-5 mt-1">
|
||||||
|
{t("serverStats.firewall.noRules")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FirewallWidget({ metrics }: FirewallWidgetProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const firewall = (
|
||||||
|
metrics as ServerMetrics & { firewall?: FirewallMetrics }
|
||||||
|
)?.firewall;
|
||||||
|
|
||||||
|
const getStatusIcon = () => {
|
||||||
|
if (!firewall || firewall.type === "none") {
|
||||||
|
return <ShieldOff className="h-5 w-5 text-muted-foreground" />;
|
||||||
|
}
|
||||||
|
if (firewall.status === "active") {
|
||||||
|
return <ShieldCheck className="h-5 w-5 text-green-400" />;
|
||||||
|
}
|
||||||
|
return <Shield className="h-5 w-5 text-orange-400" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusText = () => {
|
||||||
|
if (!firewall || firewall.type === "none") {
|
||||||
|
return t("serverStats.firewall.notDetected");
|
||||||
|
}
|
||||||
|
if (firewall.status === "active") {
|
||||||
|
return t("serverStats.firewall.active");
|
||||||
|
}
|
||||||
|
return t("serverStats.firewall.inactive");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full p-4 rounded-lg bg-canvas/50 border border-edge/50 hover:bg-canvas/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||||
|
{getStatusIcon()}
|
||||||
|
<h3 className="font-semibold text-lg text-foreground">
|
||||||
|
{t("serverStats.firewall.title")}
|
||||||
|
</h3>
|
||||||
|
{firewall && firewall.type !== "none" && (
|
||||||
|
<span className="text-xs text-muted-foreground ml-auto bg-canvas/50 px-2 py-0.5 rounded">
|
||||||
|
{firewall.type}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mb-3 flex-shrink-0">
|
||||||
|
<span
|
||||||
|
className={`text-sm font-medium ${
|
||||||
|
firewall?.status === "active"
|
||||||
|
? "text-green-400"
|
||||||
|
: firewall?.status === "inactive"
|
||||||
|
? "text-orange-400"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{getStatusText()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{firewall && firewall.chains.length > 0 ? (
|
||||||
|
<div className="flex-1 overflow-y-auto thin-scrollbar space-y-2">
|
||||||
|
{firewall.chains.map((chain) => (
|
||||||
|
<ChainSection key={chain.name} chain={chain} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("serverStats.firewall.noData")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Network } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { ServerMetrics } from "@/ui/main-axios.ts";
|
||||||
|
import type { PortsMetrics, ListeningPort } from "@/types/stats-widgets";
|
||||||
|
|
||||||
|
interface PortsWidgetProps {
|
||||||
|
metrics: ServerMetrics | null;
|
||||||
|
metricsHistory: ServerMetrics[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function PortRow({ port }: { port: ListeningPort }) {
|
||||||
|
const formatAddress = (addr: string) => {
|
||||||
|
if (addr === "0.0.0.0" || addr === "*" || addr === "::") {
|
||||||
|
return "*";
|
||||||
|
}
|
||||||
|
return addr;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-5 gap-2 text-xs py-1.5 border-b border-edge/30 last:border-0">
|
||||||
|
<div className="font-mono text-foreground-subtle">
|
||||||
|
{port.protocol.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-foreground">
|
||||||
|
{port.localPort}
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-foreground-subtle truncate" title={formatAddress(port.localAddress)}>
|
||||||
|
{formatAddress(port.localAddress)}
|
||||||
|
</div>
|
||||||
|
<div className="text-foreground-subtle">
|
||||||
|
{port.state || "-"}
|
||||||
|
</div>
|
||||||
|
<div className="text-foreground-subtle truncate" title={port.process || "-"}>
|
||||||
|
{port.process || (port.pid ? `PID:${port.pid}` : "-")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PortsWidget({ metrics }: PortsWidgetProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const portsData = (
|
||||||
|
metrics as ServerMetrics & { ports?: PortsMetrics }
|
||||||
|
)?.ports;
|
||||||
|
|
||||||
|
const tcpPorts = portsData?.ports.filter(p => p.protocol === "tcp") || [];
|
||||||
|
const udpPorts = portsData?.ports.filter(p => p.protocol === "udp") || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full p-4 rounded-lg bg-canvas/50 border border-edge/50 hover:bg-canvas/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-cyan-400" />
|
||||||
|
<h3 className="font-semibold text-lg text-foreground">
|
||||||
|
{t("serverStats.ports.title")}
|
||||||
|
</h3>
|
||||||
|
{portsData && portsData.source !== "none" && (
|
||||||
|
<span className="text-xs text-muted-foreground ml-auto bg-canvas/50 px-2 py-0.5 rounded">
|
||||||
|
{portsData.source}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 mb-3 flex-shrink-0 text-sm">
|
||||||
|
<span className="text-foreground-subtle">
|
||||||
|
TCP: <span className="text-cyan-400 font-medium">{tcpPorts.length}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-foreground-subtle">
|
||||||
|
UDP: <span className="text-cyan-400 font-medium">{udpPorts.length}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{portsData && portsData.ports.length > 0 ? (
|
||||||
|
<div className="flex-1 overflow-hidden flex flex-col">
|
||||||
|
<div className="grid grid-cols-5 gap-2 text-xs text-muted-foreground border-b border-edge/50 pb-1 mb-1 flex-shrink-0">
|
||||||
|
<div>{t("serverStats.ports.protocol")}</div>
|
||||||
|
<div>{t("serverStats.ports.port")}</div>
|
||||||
|
<div>{t("serverStats.ports.address")}</div>
|
||||||
|
<div>{t("serverStats.ports.state")}</div>
|
||||||
|
<div>{t("serverStats.ports.process")}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto thin-scrollbar">
|
||||||
|
{portsData.ports.map((port, idx) => (
|
||||||
|
<PortRow key={`${port.protocol}-${port.localPort}-${idx}`} port={port} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("serverStats.ports.noData")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user