89 Commits

Author SHA1 Message Date
Luke Gustafson
660c6440a3 New translations en.json (Bengali) 2026-01-13 03:11:24 -06:00
Luke Gustafson
6ddcfb0f3a New translations en.json (Thai) 2026-01-13 00:52:46 -06:00
Luke Gustafson
845da1a1bb New translations en.json (Bengali) 2026-01-13 00:52:45 -06:00
Luke Gustafson
bc7d6225c9 New translations en.json (Hindi) 2026-01-13 00:52:43 -06:00
Luke Gustafson
bbd06cc389 New translations en.json (Indonesian) 2026-01-13 00:52:41 -06:00
Luke Gustafson
67259f1bab New translations en.json (Bulgarian) 2026-01-13 00:52:40 -06:00
Luke Gustafson
14c1c784e1 New translations en.json (Portuguese, Brazilian) 2026-01-13 00:52:39 -06:00
Luke Gustafson
047e415cf7 New translations en.json (Vietnamese) 2026-01-13 00:52:37 -06:00
Luke Gustafson
c73ed7347e New translations en.json (Chinese Traditional) 2026-01-13 00:52:35 -06:00
Luke Gustafson
b86be3f787 New translations en.json (Chinese Simplified) 2026-01-13 00:52:34 -06:00
Luke Gustafson
15a45ebf47 New translations en.json (Ukrainian) 2026-01-13 00:52:32 -06:00
Luke Gustafson
992ec4aec3 New translations en.json (Turkish) 2026-01-13 00:52:31 -06:00
Luke Gustafson
67cc829acc New translations en.json (Serbian (Cyrillic)) 2026-01-13 00:52:29 -06:00
Luke Gustafson
569e488474 New translations en.json (Russian) 2026-01-13 00:52:27 -06:00
Luke Gustafson
34d67cd7fc New translations en.json (Portuguese) 2026-01-13 00:52:26 -06:00
Luke Gustafson
13e814567b New translations en.json (Polish) 2026-01-13 00:52:24 -06:00
Luke Gustafson
8701477bf8 New translations en.json (Dutch) 2026-01-13 00:52:22 -06:00
Luke Gustafson
15ddf655a7 New translations en.json (Korean) 2026-01-13 00:52:21 -06:00
Luke Gustafson
5faeb54fd5 New translations en.json (Japanese) 2026-01-13 00:52:20 -06:00
Luke Gustafson
dd0ae50192 New translations en.json (Italian) 2026-01-13 00:52:18 -06:00
Luke Gustafson
acfc100e58 New translations en.json (Hungarian) 2026-01-13 00:52:17 -06:00
Luke Gustafson
2496a87170 New translations en.json (Hebrew) 2026-01-13 00:52:15 -06:00
Luke Gustafson
ded43ca488 New translations en.json (Finnish) 2026-01-13 00:52:14 -06:00
Luke Gustafson
e2e9d385aa New translations en.json (Greek) 2026-01-13 00:52:12 -06:00
Luke Gustafson
87054e7271 New translations en.json (German) 2026-01-13 00:52:11 -06:00
Luke Gustafson
3165e40ee2 New translations en.json (Czech) 2026-01-13 00:52:09 -06:00
Luke Gustafson
668ebcace4 New translations en.json (Catalan) 2026-01-13 00:52:07 -06:00
Luke Gustafson
2f9984c4fc New translations en.json (Arabic) 2026-01-13 00:52:06 -06:00
Luke Gustafson
de779def67 New translations en.json (Afrikaans) 2026-01-13 00:52:04 -06:00
Luke Gustafson
49f247f507 New translations en.json (Spanish) 2026-01-13 00:52:03 -06:00
Luke Gustafson
675691f2c9 New translations en.json (French) 2026-01-13 00:52:01 -06:00
Luke Gustafson
1e91262ef6 New translations en.json (Romanian) 2026-01-13 00:52:00 -06:00
Luke Gustafson
299b499011 New translations en.json (Hindi) 2026-01-13 00:08:06 -06:00
Luke Gustafson
dca583aa7c New translations en.json (Indonesian) 2026-01-13 00:08:05 -06:00
Luke Gustafson
dffb0abde2 New translations en.json (Bulgarian) 2026-01-13 00:08:04 -06:00
Luke Gustafson
35e08f5c6b New translations en.json (Chinese Traditional) 2026-01-13 00:08:01 -06:00
Luke Gustafson
5f37a60495 New translations en.json (Chinese Simplified) 2026-01-13 00:07:59 -06:00
Luke Gustafson
f52824a626 New translations en.json (Chinese Traditional) 2026-01-12 23:56:44 -06:00
Luke Gustafson
01da42c5af New translations en.json (Chinese Simplified) 2026-01-12 23:56:43 -06:00
Luke Gustafson
72a3bae676 New translations en.json (Finnish) 2026-01-12 23:56:31 -06:00
Luke Gustafson
b6b5c06da8 New translations en.json (Chinese Traditional) 2026-01-12 06:03:33 -05:00
Luke Gustafson
c58d74819e New translations en.json (Chinese Simplified) 2026-01-12 06:03:31 -05:00
Luke Gustafson
830c5d7692 New translations en.json (Ukrainian) 2026-01-12 06:03:29 -05:00
Luke Gustafson
f3db62dc3f New translations en.json (Swedish) 2026-01-12 06:03:27 -05:00
Luke Gustafson
845a1759e5 New translations en.json (Russian) 2026-01-12 06:03:25 -05:00
Luke Gustafson
2315dbd4b4 New translations en.json (Portuguese) 2026-01-12 06:03:23 -05:00
Luke Gustafson
5f0baa7ad9 New translations en.json (Polish) 2026-01-12 06:03:22 -05:00
Luke Gustafson
fdf72d7802 New translations en.json (Norwegian) 2026-01-12 06:03:20 -05:00
Luke Gustafson
675bd58e60 New translations en.json (Dutch) 2026-01-12 06:03:18 -05:00
Luke Gustafson
35888f8716 New translations en.json (Japanese) 2026-01-12 06:03:16 -05:00
Luke Gustafson
53b28ab4d9 New translations en.json (Italian) 2026-01-12 06:03:14 -05:00
Luke Gustafson
5e4a618b7f New translations en.json (Finnish) 2026-01-12 06:03:11 -05:00
Luke Gustafson
9c273c8c42 New translations en.json (Greek) 2026-01-12 06:03:09 -05:00
Luke Gustafson
d2a9115a07 New translations en.json (Danish) 2026-01-12 06:03:07 -05:00
Luke Gustafson
924c7a1f8e New translations en.json (Czech) 2026-01-12 06:03:05 -05:00
Luke Gustafson
81c70a6f26 New translations en.json (Arabic) 2026-01-12 06:03:03 -05:00
Luke Gustafson
9ddbdc5823 New translations en.json (Spanish) 2026-01-12 06:03:00 -05:00
Luke Gustafson
ed728ae018 New translations en.json (French) 2026-01-12 06:02:58 -05:00
Luke Gustafson
67ca8a7bcd New translations en.json (Romanian) 2026-01-12 06:02:57 -05:00
Luke Gustafson
7c1ff50390 New translations en.json (Norwegian) 2026-01-12 03:00:43 -05:00
Luke Gustafson
beef66ce65 New translations en.json (German) 2026-01-01 02:23:55 -06:00
Luke Gustafson
e1e8b4cc29 New translations en.json (Vietnamese) 2026-01-01 01:58:03 -06:00
Luke Gustafson
dbc5be2d02 New translations en.json (English) 2026-01-01 01:58:01 -06:00
Luke Gustafson
c41a689007 New translations en.json (Chinese Simplified) 2026-01-01 01:58:00 -06:00
Luke Gustafson
e6b620b236 New translations en.json (Ukrainian) 2026-01-01 01:57:58 -06:00
Luke Gustafson
e17c52bc70 New translations en.json (Turkish) 2026-01-01 01:57:57 -06:00
Luke Gustafson
d8cd06d68e New translations en.json (Swedish) 2026-01-01 01:57:56 -06:00
Luke Gustafson
31d3490cd7 New translations en.json (Serbian (Cyrillic)) 2026-01-01 01:57:55 -06:00
Luke Gustafson
27e32b9b21 New translations en.json (Russian) 2026-01-01 01:57:54 -06:00
Luke Gustafson
b2e0b58e0d New translations en.json (Portuguese) 2026-01-01 01:57:53 -06:00
Luke Gustafson
e38308325c New translations en.json (Polish) 2026-01-01 01:57:51 -06:00
Luke Gustafson
45c643ef2e New translations en.json (Norwegian) 2026-01-01 01:57:50 -06:00
Luke Gustafson
0ba6ecd7a2 New translations en.json (Dutch) 2026-01-01 01:57:49 -06:00
Luke Gustafson
c300c429b0 New translations en.json (Korean) 2026-01-01 01:57:48 -06:00
Luke Gustafson
11c0ec855d New translations en.json (Japanese) 2026-01-01 01:57:46 -06:00
Luke Gustafson
377767f7d5 New translations en.json (Italian) 2026-01-01 01:57:45 -06:00
Luke Gustafson
07a933e5bd New translations en.json (Hungarian) 2026-01-01 01:57:44 -06:00
Luke Gustafson
c7846a7e6d New translations en.json (Hebrew) 2026-01-01 01:57:43 -06:00
Luke Gustafson
44a1bfdc46 New translations en.json (Finnish) 2026-01-01 01:57:42 -06:00
Luke Gustafson
116e9e2fe6 New translations en.json (Greek) 2026-01-01 01:57:40 -06:00
Luke Gustafson
dc8c89d645 New translations en.json (German) 2026-01-01 01:57:39 -06:00
Luke Gustafson
2abace5580 New translations en.json (Danish) 2026-01-01 01:57:38 -06:00
Luke Gustafson
50a6408736 New translations en.json (Czech) 2026-01-01 01:57:37 -06:00
Luke Gustafson
2824352f35 New translations en.json (Catalan) 2026-01-01 01:57:36 -06:00
Luke Gustafson
683b015913 New translations en.json (Arabic) 2026-01-01 01:57:35 -06:00
Luke Gustafson
012a3e07b8 New translations en.json (Afrikaans) 2026-01-01 01:57:33 -06:00
Luke Gustafson
fb33346b67 New translations en.json (Spanish) 2026-01-01 01:57:32 -06:00
Luke Gustafson
aad3410b42 New translations en.json (French) 2026-01-01 01:57:31 -06:00
Luke Gustafson
eb7da4acac New translations en.json (Romanian) 2026-01-01 01:57:30 -06:00
127 changed files with 64311 additions and 13233 deletions

View File

@@ -17,7 +17,7 @@ on:
jobs: jobs:
build: build:
runs-on: blacksmith-4vcpu-ubuntu-2404 runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v5

View File

@@ -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') && github.event.inputs.artifact_destination != 'submit' if: github.event.inputs.build_type == 'macos' || github.event.inputs.build_type == 'all'
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' && (github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'windows' || github.event.inputs.build_type == '') if: github.event.inputs.artifact_destination == 'submit'
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' && (github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'linux' || github.event.inputs.build_type == '') if: github.event.inputs.artifact_destination == 'submit'
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' && (github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'macos') if: github.event.inputs.artifact_destination == 'submit'
needs: [] needs: []
permissions: permissions:
contents: read contents: read
@@ -801,20 +801,11 @@ 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
DOWNLOAD_PATH="release_asset/$DMG_NAME" PATH="release_asset/$DMG_NAME"
echo "Downloading DMG from $URL" echo "Downloading DMG from $URL"
curl -L -o "$PATH" "$URL"
if command -v curl &> /dev/null; then CHECKSUM=$(shasum -a 256 "$PATH" | awk '{print $1}')
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
@@ -881,7 +872,7 @@ jobs:
submit-to-testflight: submit-to-testflight:
runs-on: macos-latest runs-on: macos-latest
if: github.event.inputs.artifact_destination == 'submit' && (github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'macos') if: github.event.inputs.artifact_destination == 'submit'
needs: [] needs: []
permissions: permissions:
contents: write contents: write
@@ -986,7 +977,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 release -name "termix_macos_universal_mas.pkg" -type f | head -n 1) PKG_FILE=$(find artifact-mas -name "*.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

View File

@@ -1,32 +0,0 @@
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 Normal file
View File

@@ -0,0 +1,437 @@
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"

View File

@@ -16,6 +16,17 @@
<small style="color: #666;">Achieved on September 1st, 2025</small> <small style="color: #666;">Achieved on September 1st, 2025</small>
</p> </p>
#### Top Technologies
[![React Badge](https://img.shields.io/badge/-React-61DBFB?style=flat-square&labelColor=black&logo=react&logoColor=61DBFB)](#)
[![TypeScript Badge](https://img.shields.io/badge/-TypeScript-3178C6?style=flat-square&labelColor=black&logo=typescript&logoColor=3178C6)](#)
[![Node.js Badge](https://img.shields.io/badge/-Node.js-3C873A?style=flat-square&labelColor=black&logo=node.js&logoColor=3C873A)](#)
[![Vite Badge](https://img.shields.io/badge/-Vite-646CFF?style=flat-square&labelColor=black&logo=vite&logoColor=646CFF)](#)
[![Tailwind CSS Badge](https://img.shields.io/badge/-TailwindCSS-38B2AC?style=flat-square&labelColor=black&logo=tailwindcss&logoColor=38B2AC)](#)
[![Docker Badge](https://img.shields.io/badge/-Docker-2496ED?style=flat-square&labelColor=black&logo=docker&logoColor=2496ED)](#)
[![SQLite Badge](https://img.shields.io/badge/-SQLite-003B57?style=flat-square&labelColor=black&logo=sqlite&logoColor=003B57)](#)
[![Radix UI Badge](https://img.shields.io/badge/-Radix%20UI-161618?style=flat-square&labelColor=black&logo=radixui&logoColor=161618)](#)
<br /> <br />
<p align="center"> <p align="center">
<a href="https://github.com/Termix-SSH/Termix"> <a href="https://github.com/Termix-SSH/Termix">
@@ -74,7 +85,6 @@ Supported Devices:
- Chocolatey Package Manager - Chocolatey Package Manager
- Linux (x64/ia32) - Linux (x64/ia32)
- Portable - Portable
- AUR
- AppImage - AppImage
- Deb - Deb
- Flatpak - Flatpak
@@ -110,18 +120,6 @@ 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>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<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`.

View File

@@ -1,3 +0,0 @@
files:
- source: /src/locales/en.json
translation: /src/locales/translated/%two_letters_code%.json

View File

@@ -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 && \
NODE_OPTIONS="--max-old-space-size=2048" npm run build npm run build
# Stage 3: Build backend # Stage 3: Build backend
FROM deps AS backend-builder FROM deps AS backend-builder
@@ -74,9 +74,6 @@ 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

View File

@@ -1,5 +1,3 @@
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;
@@ -201,18 +199,6 @@ 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;
@@ -300,15 +286,6 @@ 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;
@@ -358,15 +335,6 @@ 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;

View File

@@ -1,5 +1,3 @@
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;
@@ -190,18 +188,6 @@ 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;
@@ -289,15 +275,6 @@ 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;
@@ -347,15 +324,6 @@ 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;

View File

@@ -4,13 +4,6 @@
<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 Normal file

File diff suppressed because it is too large Load Diff

262
package-lock.json generated
View File

@@ -34,7 +34,6 @@
"@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",
@@ -57,7 +56,6 @@
"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",
@@ -74,7 +72,6 @@
"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",
@@ -125,60 +122,11 @@
"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",
@@ -2671,13 +2619,6 @@
"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",
@@ -5287,12 +5228,6 @@
"@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",
@@ -7023,13 +6958,6 @@
"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",
@@ -7848,15 +7776,6 @@
"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",
@@ -8385,19 +8304,6 @@
"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",
@@ -12289,14 +12195,6 @@
"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",
@@ -12309,14 +12207,6 @@
"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",
@@ -14178,14 +14068,6 @@
"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",
@@ -15227,19 +15109,6 @@
"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",
@@ -16848,95 +16717,6 @@
"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",
@@ -17786,16 +17566,6 @@
"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",
@@ -18350,38 +18120,6 @@
"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",

View File

@@ -1,7 +1,7 @@
{ {
"name": "termix", "name": "termix",
"private": true, "private": true,
"version": "1.10.1", "version": "1.10.0",
"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,7 +17,6 @@
"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",
@@ -54,7 +53,6 @@
"@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",
@@ -77,7 +75,6 @@
"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",
@@ -94,7 +91,6 @@
"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",
@@ -145,7 +141,6 @@
"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"

View File

@@ -1,40 +0,0 @@
{
"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"]
}

View File

@@ -1,120 +0,0 @@
/**
* 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;
});
}),
);
});

View File

@@ -1,14 +1,9 @@
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, DatabaseSaveTrigger } from "./database/db/index.js"; import { getDb } from "./database/db/index.js";
import { import { recentActivity, sshData, hostAccess } from "./database/db/schema.js";
recentActivity, import { eq, and, desc, or } from "drizzle-orm";
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";
@@ -63,31 +58,6 @@ 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;
@@ -107,28 +77,6 @@ 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;
@@ -160,40 +108,6 @@ 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;
@@ -310,22 +224,6 @@ 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;
@@ -355,166 +253,6 @@ 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 {

View File

@@ -8,7 +8,6 @@ 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";
@@ -206,46 +205,10 @@ 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;
@@ -344,31 +307,6 @@ 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;
@@ -425,20 +363,6 @@ 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 = {
@@ -460,20 +384,6 @@ 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();
@@ -497,20 +407,6 @@ 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", {
@@ -532,20 +428,6 @@ 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", {
@@ -566,33 +448,6 @@ 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;
@@ -1043,36 +898,6 @@ 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,
@@ -1537,31 +1362,6 @@ 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;
@@ -1597,33 +1397,6 @@ 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;
@@ -1664,7 +1437,6 @@ 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(
@@ -1705,20 +1477,6 @@ 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,
@@ -1772,20 +1530,6 @@ 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,

View File

@@ -585,32 +585,6 @@ 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");
@@ -679,54 +653,6 @@ 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 {

View File

@@ -90,21 +90,6 @@ 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"),
@@ -310,35 +295,6 @@ 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")

View File

@@ -99,20 +99,8 @@ 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)
* @openapi // GET /alerts
* /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;
@@ -143,33 +131,8 @@ router.get("/", authenticateJWT, async (req, res) => {
} }
}); });
/** // Route: Dismiss an alert for the authenticated user
* @openapi // POST /alerts/dismiss
* /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;
@@ -207,20 +170,8 @@ router.post("/dismiss", authenticateJWT, async (req, res) => {
} }
}); });
/** // Route: Get dismissed alerts for a user
* @openapi // GET /alerts/dismissed/:userId
* /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;
@@ -243,33 +194,8 @@ router.get("/dismissed", authenticateJWT, async (req, res) => {
} }
}); });
/** // Route: Undismiss an alert for the authenticated user (remove from dismissed list)
* @openapi // DELETE /alerts/dismiss
* /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;

View File

@@ -84,52 +84,8 @@ const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware(); const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware(); const requireDataAccess = authManager.createDataAccessMiddleware();
/** // Create a new credential
* @openapi // POST /credentials
* /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,
@@ -275,22 +231,8 @@ router.post(
}, },
); );
/** // Get all credentials for the authenticated user
* @openapi // GET /credentials
* /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,
@@ -322,22 +264,8 @@ router.get(
}, },
); );
/** // Get all unique credential folders for the authenticated user
* @openapi // GET /credentials/folders
* /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,
@@ -374,30 +302,8 @@ router.get(
}, },
); );
/** // Get a specific credential by ID (with plain text secrets)
* @openapi // GET /credentials/:id
* /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,
@@ -460,41 +366,8 @@ router.get(
}, },
); );
/** // Update a credential
* @openapi // PUT /credentials/:id
* /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,
@@ -637,30 +510,8 @@ router.put(
}, },
); );
/** // Delete a credential
* @openapi // DELETE /credentials/:id
* /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,
@@ -775,35 +626,8 @@ router.delete(
}, },
); );
/** // Apply a credential to an SSH host (for quick application)
* @openapi // POST /credentials/:id/apply-to-host/:hostId
* /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,
@@ -881,28 +705,8 @@ router.post(
}, },
); );
/** // Get hosts using a specific credential
* @openapi // GET /credentials/:id/hosts
* /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,
@@ -996,33 +800,8 @@ function formatSSHHostOutput(
}; };
} }
/** // Rename a credential folder
* @openapi // PUT /credentials/folders/rename
* /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,
@@ -1061,33 +840,8 @@ router.put(
}, },
); );
/** // Detect SSH key type endpoint
* @openapi // POST /credentials/detect-key-type
* /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,
@@ -1120,31 +874,8 @@ router.post(
}, },
); );
/** // Detect SSH public key type endpoint
* @openapi // POST /credentials/detect-public-key-type
* /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,
@@ -1178,35 +909,8 @@ router.post(
}, },
); );
/** // Validate SSH key pair endpoint
* @openapi // POST /credentials/validate-key-pair
* /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,
@@ -1249,32 +953,8 @@ router.post(
}, },
); );
/** // Generate new SSH key pair endpoint
* @openapi // POST /credentials/generate-key-pair
* /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,
@@ -1316,33 +996,8 @@ router.post(
}, },
); );
/** // Generate public key from private key endpoint
* @openapi // POST /credentials/generate-public-key
* /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,
@@ -1628,7 +1283,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);
@@ -1847,41 +1502,8 @@ async function deploySSHKeyToHost(
}); });
} }
/** // Deploy SSH Key to Host endpoint
* @openapi // POST /credentials/:id/deploy-to-host
* /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,

View File

@@ -1,142 +0,0 @@
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;

View File

@@ -27,51 +27,8 @@ 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
* @openapi //POST /rbac/host/:id/share
* /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,
@@ -270,35 +227,8 @@ router.post(
}, },
); );
/** // Revoke host access
* @openapi // DELETE /rbac/host/:id/access/:accessId
* /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,
@@ -337,30 +267,8 @@ router.delete(
}, },
); );
/** // Get host access list
* @openapi // GET /rbac/host/:id/access
* /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,
@@ -430,20 +338,8 @@ router.get(
}, },
); );
/** // Get user's shared hosts (hosts shared WITH this user)
* @openapi // GET /rbac/shared-hosts
* /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,
@@ -489,20 +385,8 @@ router.get(
}, },
); );
/** // Get all roles
* @openapi // GET /rbac/roles
* /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,
@@ -529,20 +413,8 @@ router.get(
}, },
); );
/** // Get all roles
* @openapi // GET /rbac/roles
* /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,
@@ -571,37 +443,8 @@ router.get(
}, },
); );
/** // Create new role
* @openapi // POST /rbac/roles
* /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,
@@ -660,41 +503,8 @@ router.post(
}, },
); );
/** // Update role
* @openapi // PUT /rbac/roles/:id
* /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,
@@ -760,32 +570,8 @@ router.put(
}, },
); );
/** // Delete role
* @openapi // DELETE /rbac/roles/:id
* /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,
@@ -848,43 +634,8 @@ router.delete(
}, },
); );
/** // Assign role to user
* @openapi // POST /rbac/users/:userId/roles
* /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,
@@ -995,37 +746,8 @@ router.post(
}, },
); );
/** // Remove role from user
* @openapi // DELETE /rbac/users/:userId/roles/:roleId
* /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,
@@ -1083,28 +805,8 @@ router.delete(
}, },
); );
/** // Get user's roles
* @openapi // GET /rbac/users/:userId/roles
* /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,

View File

@@ -17,22 +17,8 @@ const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware(); const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware(); const requireDataAccess = authManager.createDataAccessMiddleware();
/** // Get all snippet folders
* @openapi // GET /snippets/folders
* /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,
@@ -60,37 +46,8 @@ router.get(
}, },
); );
/** // Create a new snippet folder
* @openapi // POST /snippets/folders
* /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,
@@ -153,41 +110,8 @@ router.post(
}, },
); );
/** // Update snippet folder metadata (color, icon)
* @openapi // PUT /snippets/folders/:name/metadata
* /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,
@@ -270,37 +194,8 @@ router.put(
}, },
); );
/** // Rename snippet folder
* @openapi // PUT /snippets/folders/rename
* /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,
@@ -387,28 +282,8 @@ router.put(
}, },
); );
/** // Delete snippet folder
* @openapi // DELETE /snippets/folders/:name
* /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,
@@ -463,40 +338,8 @@ router.delete(
}, },
); );
/** // Reorder snippets (bulk update)
* @openapi // PUT /snippets/reorder
* /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,
@@ -562,35 +405,8 @@ router.put(
}, },
); );
/** // Execute a snippet on a host
* @openapi // POST /snippets/execute
* /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,
@@ -846,22 +662,8 @@ router.post(
}, },
); );
/** // Get all snippets for the authenticated user
* @openapi // GET /snippets
* /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,
@@ -894,30 +696,8 @@ router.get(
}, },
); );
/** // Get a specific snippet by ID
* @openapi // GET /snippets/:id
* /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,
@@ -955,39 +735,8 @@ router.get(
}, },
); );
/** // Create a new snippet
* @openapi // POST /snippets
* /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,
@@ -1057,47 +806,8 @@ router.post(
}, },
); );
/** // Update a snippet
* @openapi // PUT /snippets/:id
* /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,
@@ -1173,30 +883,8 @@ router.put(
}, },
); );
/** // Delete a snippet
* @openapi // DELETE /snippets/:id
* /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

View File

@@ -17,33 +17,8 @@ const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware(); const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware(); const requireDataAccess = authManager.createDataAccessMiddleware();
/** // Save command to history
* @openapi // POST /terminal/command_history
* /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,
@@ -84,28 +59,8 @@ router.post(
}, },
); );
/** // Get command history for a specific host
* @openapi // GET /terminal/command_history/:hostId
* /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,
@@ -152,33 +107,8 @@ router.get(
}, },
); );
/** // Delete a specific command from history
* @openapi // POST /terminal/command_history/delete
* /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,
@@ -220,28 +150,8 @@ router.post(
}, },
); );
/** // Clear command history for a specific host (optional feature)
* @openapi // DELETE /terminal/command_history/:hostId
* /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

View File

@@ -365,34 +365,7 @@ 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,
@@ -956,29 +929,7 @@ 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;
@@ -991,35 +942,7 @@ 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;
@@ -1182,29 +1105,7 @@ 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;
@@ -1232,26 +1133,7 @@ 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;
@@ -1264,28 +1146,7 @@ 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;
@@ -1375,32 +1236,7 @@ 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";
@@ -1461,35 +1297,7 @@ 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;
@@ -1548,35 +1356,7 @@ 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) => {
@@ -1634,35 +1414,7 @@ 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) => {
@@ -1720,35 +1472,7 @@ 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) => {
@@ -1806,35 +1530,7 @@ 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) => {
@@ -1892,35 +1588,7 @@ 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) => {
@@ -1978,39 +1646,7 @@ 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) => {
@@ -2082,51 +1718,7 @@ 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;
@@ -2203,35 +1795,7 @@ 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

View File

@@ -19,8 +19,6 @@ 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(
@@ -1784,62 +1782,6 @@ 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,
@@ -1849,8 +1791,6 @@ 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);
@@ -1924,20 +1864,6 @@ 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;
@@ -1960,28 +1886,6 @@ 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;
@@ -2006,20 +1910,6 @@ 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;
@@ -2034,20 +1924,6 @@ 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;
@@ -2064,35 +1940,6 @@ 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;
@@ -2128,33 +1975,6 @@ 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;
@@ -2183,28 +2003,6 @@ 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;
@@ -2242,30 +2040,6 @@ 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;
@@ -2445,37 +2219,6 @@ 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;
@@ -2518,37 +2261,6 @@ 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;
@@ -2684,35 +2396,6 @@ 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;
@@ -2745,33 +2428,6 @@ 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;
@@ -2802,35 +2458,6 @@ 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;

View File

@@ -648,7 +648,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
); );
cleanupSSH(connectionTimeout); cleanupSSH(connectionTimeout);
} }
}, 120000); }, 30000);
let resolvedCredentials = { password, key, keyPassword, keyType, authType }; let resolvedCredentials = { password, key, keyPassword, keyType, authType };
let authMethodNotAvailable = false; let authMethodNotAvailable = false;
@@ -761,36 +761,6 @@ 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,
@@ -798,8 +768,6 @@ 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) {
@@ -816,7 +784,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
message: "Shell error: " + err.message, message: "Shell error: " + err.message,
}), }),
); );
cleanupSSH(connectionTimeout);
return; return;
} }
@@ -1002,31 +969,6 @@ 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);
}); });
@@ -1173,10 +1115,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
tryKeyboard: true, tryKeyboard: true,
keepaliveInterval: 30000, keepaliveInterval: 30000,
keepaliveCountMax: 3, keepaliveCountMax: 3,
readyTimeout: 120000, readyTimeout: 30000,
tcpKeepAlive: true, tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000, tcpKeepAliveInitialDelay: 30000,
timeout: 120000, timeout: 30000,
env: { env: {
TERM: "xterm-256color", TERM: "xterm-256color",
LANG: "en_US.UTF-8", LANG: "en_US.UTF-8",

View File

@@ -828,22 +828,15 @@ 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 ${tunnelFlag} ${portMapping} ${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 -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${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 ${tunnelFlag} ${portMapping} ${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 -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}`;
} }
conn.exec(tunnelCmd, (err, stream) => { conn.exec(tunnelCmd, (err, stream) => {
@@ -1309,9 +1302,7 @@ async function killRemoteTunnelByMarker(
} }
conn.on("ready", () => { conn.on("ready", () => {
const tunnelType = tunnelConfig.tunnelType || "remote"; 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 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;
@@ -1332,8 +1323,8 @@ async function killRemoteTunnelByMarker(
const killCmds = [ const killCmds = [
`pkill -TERM -f '${tunnelMarker}'`, `pkill -TERM -f '${tunnelMarker}'`,
`sleep 1 && pkill -f 'ssh.*${tunnelFlag}.*${tunnelConfig.endpointPort}:.*:${tunnelConfig.sourcePort}.*${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}'`, `sleep 1 && pkill -f 'ssh.*-R.*${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort}.*${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}'`,
`sleep 1 && pkill -f 'sshpass.*ssh.*${tunnelFlag}.*${tunnelConfig.endpointPort}'`, `sleep 1 && pkill -f 'sshpass.*ssh.*-R.*${tunnelConfig.endpointPort}'`,
`sleep 2 && pkill -9 -f '${tunnelMarker}'`, `sleep 2 && pkill -9 -f '${tunnelMarker}'`,
]; ];
@@ -1459,42 +1450,10 @@ 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);
@@ -1506,39 +1465,6 @@ 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,
@@ -1693,35 +1619,6 @@ 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,
@@ -1786,35 +1683,6 @@ 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,
@@ -1938,7 +1806,6 @@ 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}`,

View File

@@ -1,254 +0,0 @@
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: [],
};
}
}

View File

@@ -1,155 +0,0 @@
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: [],
};
}
}

View File

@@ -1,145 +0,0 @@
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 };

View File

@@ -32,6 +32,7 @@ class FieldCrypto {
"key", "key",
"key_password", "key_password",
"keyPassword", "keyPassword",
"keyType",
"autostartPassword", "autostartPassword",
"autostartKey", "autostartKey",
"autostartKeyPassword", "autostartKeyPassword",
@@ -45,6 +46,7 @@ class FieldCrypto {
"key", "key",
"public_key", "public_key",
"publicKey", "publicKey",
"keyType",
]), ]),
}; };

View File

@@ -7,21 +7,11 @@ 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);
} }
@@ -50,28 +40,6 @@ 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 {
@@ -173,114 +141,6 @@ 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();

View File

@@ -177,57 +177,30 @@ class UserDataImport {
continue; continue;
} }
const existing = await getDb() const tempId = `import-ssh-${targetUserId}-${Date.now()}-${imported}`;
.select() const newHostData = {
.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(),
}; };
if (existing.length === 0) { let processedHostData = newHostData;
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;
if (existing.length > 0 && options.replaceExisting) { await getDb()
await getDb() .insert(sshData)
.update(sshData) .values(processedHostData as unknown as typeof sshData.$inferInsert);
.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(
@@ -260,59 +233,34 @@ class UserDataImport {
continue; continue;
} }
const existing = await getDb() const tempCredId = `import-cred-${targetUserId}-${Date.now()}-${imported}`;
.select() const newCredentialData = {
.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(),
}; };
if (existing.length === 0) { let processedCredentialData = newCredentialData;
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;
if (existing.length > 0 && options.replaceExisting) { await getDb()
await getDb() .insert(sshCredentials)
.update(sshCredentials) .values(
.set( processedCredentialData as unknown as typeof sshCredentials.$inferInsert,
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(

View File

@@ -36,7 +36,7 @@ function TooltipTrigger({
function TooltipContent({ function TooltipContent({
className, className,
sideOffset = 4, sideOffset = 0,
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-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", "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",
className, className,
)} )}
{...props} {...props}

View File

@@ -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.0, lineHeight: 1.2,
theme: "termix", theme: "termix",
scrollback: 10000, scrollback: 10000,

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
interface ConfirmationOptions { interface ConfirmationOptions {
@@ -9,47 +9,10 @@ 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);
@@ -77,7 +40,6 @@ 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 =
@@ -85,56 +47,43 @@ export function useConfirmation() {
variantOrConfirmLabel === "destructive"; variantOrConfirmLabel === "destructive";
const confirmLabel = isVariant ? "Confirm" : variantOrConfirmLabel; const confirmLabel = isVariant ? "Confirm" : variantOrConfirmLabel;
const { confirmOnEnter = false, duration = 8000 } = toastOptions; if (typeof opts === "string") {
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;
const handleToastConfirm = () => { toast(opts.description, {
if (callback) callback(); action: {
resolve(true); label: actualConfirmLabel,
setActiveToastId(null); onClick: () => {
setPendingConfirmCallback(null); if (callback) callback();
setPendingResolve(null); resolve(true);
}; },
},
const handleToastCancel = () => { cancel: {
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);
} }
}); });
}; };

View File

@@ -1,71 +0,0 @@
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;
}

View File

@@ -3,38 +3,31 @@ 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 afTranslation from "../locales/translated/af.json"; import zhTranslation from "../locales/zh.json";
import arTranslation from "../locales/translated/ar.json"; import deTranslation from "../locales/de.json";
import bnTranslation from "../locales/translated/bn.json"; import ptTranslation from "../locales/pt.json";
import bgTranslation from "../locales/translated/bg.json"; import ruTranslation from "../locales/ru.json";
import caTranslation from "../locales/translated/ca.json"; import frTranslation from "../locales/fr.json";
import csTranslation from "../locales/translated/cs.json"; import koTranslation from "../locales/ko.json";
import daTranslation from "../locales/translated/da.json"; import itTranslation from "../locales/it.json";
import deTranslation from "../locales/translated/de.json"; import esTranslation from "../locales/es.json";
import elTranslation from "../locales/translated/el.json"; import hiTranslation from "../locales/hi.json";
import esTranslation from "../locales/translated/es.json"; import bnTranslation from "../locales/bn.json";
import fiTranslation from "../locales/translated/fi.json"; import jaTranslation from "../locales/ja.json";
import frTranslation from "../locales/translated/fr.json"; import viTranslation from "../locales/vi.json";
import heTranslation from "../locales/translated/he.json"; import trTranslation from "../locales/tr.json";
import hiTranslation from "../locales/translated/hi.json"; import heTranslation from "../locales/he.json";
import huTranslation from "../locales/translated/hu.json"; import arTranslation from "../locales/ar.json";
import idTranslation from "../locales/translated/id.json"; import plTranslation from "../locales/pl.json";
import itTranslation from "../locales/translated/it.json"; import nlTranslation from "../locales/nl.json";
import jaTranslation from "../locales/translated/ja.json"; import svTranslation from "../locales/sv.json";
import koTranslation from "../locales/translated/ko.json"; import idTranslation from "../locales/id.json";
import nlTranslation from "../locales/translated/nl.json"; import thTranslation from "../locales/th.json";
import noTranslation from "../locales/translated/no.json"; import ukTranslation from "../locales/uk.json";
import plTranslation from "../locales/translated/pl.json"; import csTranslation from "../locales/cs.json";
import ptTranslation from "../locales/translated/pt.json"; import roTranslation from "../locales/ro.json";
import roTranslation from "../locales/translated/ro.json"; import elTranslation from "../locales/el.json";
import ruTranslation from "../locales/translated/ru.json"; import nbTranslation from "../locales/nb.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)
@@ -42,38 +35,31 @@ i18n
.init({ .init({
supportedLngs: [ supportedLngs: [
"en", "en",
"af",
"ar",
"bn",
"bg",
"ca",
"cs",
"da",
"de",
"el",
"es",
"fi",
"fr",
"he",
"hi",
"hu",
"id",
"it",
"ja",
"ko",
"nl",
"no",
"pl",
"pt",
"ro",
"ru",
"sr",
"sv",
"th",
"tr",
"uk",
"vi",
"zh", "zh",
"de",
"pt",
"ru",
"fr",
"ko",
"it",
"es",
"hi",
"bn",
"ja",
"vi",
"tr",
"he",
"ar",
"pl",
"nl",
"sv",
"id",
"th",
"uk",
"cs",
"ro",
"el",
"nb",
], ],
fallbackLng: "en", fallbackLng: "en",
debug: false, debug: false,
@@ -90,101 +76,80 @@ i18n
en: { en: {
translation: enTranslation, translation: enTranslation,
}, },
af: { zh: {
translation: afTranslation, translation: zhTranslation,
},
ar: {
translation: arTranslation,
},
bn: {
translation: bnTranslation,
},
bg: {
translation: bgTranslation,
},
ca: {
translation: caTranslation,
},
cs: {
translation: csTranslation,
},
da: {
translation: daTranslation,
}, },
de: { de: {
translation: deTranslation, translation: deTranslation,
}, },
el: {
translation: elTranslation,
},
es: {
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: { pt: {
translation: ptTranslation, translation: ptTranslation,
}, },
ro: {
translation: roTranslation,
},
ru: { ru: {
translation: ruTranslation, translation: ruTranslation,
}, },
sr: { fr: {
translation: srTranslation, translation: frTranslation,
}, },
sv: { ko: {
translation: svTranslation, translation: koTranslation,
}, },
th: { it: {
translation: thTranslation, translation: itTranslation,
}, },
tr: { es: {
translation: trTranslation, translation: esTranslation,
}, },
uk: { hi: {
translation: ukTranslation, translation: hiTranslation,
},
bn: {
translation: bnTranslation,
},
ja: {
translation: jaTranslation,
}, },
vi: { vi: {
translation: viTranslation, translation: viTranslation,
}, },
zh: { tr: {
translation: zhTranslation, translation: trTranslation,
},
he: {
translation: heTranslation,
},
ar: {
translation: arTranslation,
},
pl: {
translation: plTranslation,
},
nl: {
translation: nlTranslation,
},
sv: {
translation: svTranslation,
},
id: {
translation: idTranslation,
},
th: {
translation: thTranslation,
},
uk: {
translation: ukTranslation,
},
cs: {
translation: csTranslation,
},
ro: {
translation: roTranslation,
},
el: {
translation: elTranslation,
},
nb: {
translation: nbTranslation,
}, },
}, },

View File

@@ -1,104 +0,0 @@
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 Normal file

File diff suppressed because it is too large Load Diff

2402
src/locales/bn.json Normal file

File diff suppressed because it is too large Load Diff

2402
src/locales/cs.json Normal file

File diff suppressed because it is too large Load Diff

2402
src/locales/de.json Normal file

File diff suppressed because it is too large Load Diff

2402
src/locales/el.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -44,8 +44,6 @@
"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",
@@ -186,32 +184,6 @@
"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}}",
@@ -492,7 +464,6 @@
"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",
@@ -518,12 +489,7 @@
"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",
@@ -571,7 +537,6 @@
"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",
@@ -891,13 +856,6 @@
"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:",
@@ -1148,19 +1106,6 @@
"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",
@@ -1415,12 +1360,6 @@
"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",
@@ -1792,34 +1731,7 @@
"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",
@@ -1929,8 +1841,7 @@
"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",
@@ -1962,11 +1873,7 @@
"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",
@@ -2024,9 +1931,6 @@
"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.",
@@ -2035,9 +1939,7 @@
"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"
@@ -2261,20 +2163,7 @@
"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 Normal file

File diff suppressed because it is too large Load Diff

2402
src/locales/fr.json Normal file

File diff suppressed because it is too large Load Diff

2402
src/locales/he.json Normal file

File diff suppressed because it is too large Load Diff

2402
src/locales/hi.json Normal file

File diff suppressed because it is too large Load Diff

2402
src/locales/id.json Normal file

File diff suppressed because it is too large Load Diff

2402
src/locales/it.json Normal file

File diff suppressed because it is too large Load Diff

2402
src/locales/ja.json Normal file

File diff suppressed because it is too large Load Diff

2402
src/locales/ko.json Normal file

File diff suppressed because it is too large Load Diff

2402
src/locales/nb.json Normal file

File diff suppressed because it is too large Load Diff

2402
src/locales/nl.json Normal file

File diff suppressed because it is too large Load Diff

2402
src/locales/pl.json Normal file

File diff suppressed because it is too large Load Diff

2402
src/locales/pt.json Normal file

File diff suppressed because it is too large Load Diff

2402
src/locales/ro.json Normal file

File diff suppressed because it is too large Load Diff

2402
src/locales/ru.json Normal file

File diff suppressed because it is too large Load Diff

2402
src/locales/sv.json Normal file

File diff suppressed because it is too large Load Diff

2402
src/locales/th.json Normal file

File diff suppressed because it is too large Load Diff

2402
src/locales/tr.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1183,7 +1183,7 @@
"pause": "বিরতি", "pause": "বিরতি",
"restart": "পুনরারম্ভ করুন", "restart": "পুনরারম্ভ করুন",
"removeContainer": "কন্টেইনার সরান", "removeContainer": "কন্টেইনার সরান",
"confirmRemoveContainer": "Are you sure you want to remove container \"{{name}}\"?", "confirmRemoveContainer": "আপনি কি নিশ্চিত যে আপনি পাত্রে অপসারণ করতে চান {{name}}?",
"runningContainerWarning": "সতর্কতা: এই কন্টেইনারটি বর্তমানে চলছে এবং জোর করে সরিয়ে ফেলা হবে।", "runningContainerWarning": "সতর্কতা: এই কন্টেইনারটি বর্তমানে চলছে এবং জোর করে সরিয়ে ফেলা হবে।",
"removing": "অপসারণ:", "removing": "অপসারণ:",
"containerNotFound": "কন্টেইনারটি পাওয়া যায়নি", "containerNotFound": "কন্টেইনারটি পাওয়া যায়নি",

View File

@@ -1371,10 +1371,6 @@
"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",

View File

@@ -1370,11 +1370,7 @@
"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 Normal file

File diff suppressed because it is too large Load Diff

2402
src/locales/vi.json Normal file

File diff suppressed because it is too large Load Diff

2402
src/locales/zh.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -8,23 +8,6 @@ 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);
@@ -75,21 +58,11 @@ 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 />;
} }
@@ -121,7 +94,7 @@ function RootApp() {
}} }}
/> />
<div className="relative min-h-screen" style={{ zIndex: 1 }}> <div className="relative min-h-screen" style={{ zIndex: 1 }}>
{isElectron() && showVersionCheck && !isFullscreen ? ( {isElectron() && showVersionCheck ? (
<ElectronVersionCheck <ElectronVersionCheck
onContinue={() => setShowVersionCheck(false)} onContinue={() => setShowVersionCheck(false)}
isAuthenticated={false} isAuthenticated={false}
@@ -141,4 +114,3 @@ createRoot(document.getElementById("root")!).render(
</ThemeProvider> </ThemeProvider>
</StrictMode>, </StrictMode>,
); );

View File

@@ -42,11 +42,6 @@ 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[];
@@ -107,11 +102,6 @@ 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[];
@@ -203,7 +193,6 @@ export interface CredentialData {
// ============================================================================ // ============================================================================
export interface TunnelConnection { export interface TunnelConnection {
tunnelType?: "local" | "remote";
sourcePort: number; sourcePort: number;
endpointPort: number; endpointPort: number;
endpointHost: string; endpointHost: string;
@@ -221,7 +210,6 @@ export interface TunnelConnection {
export interface TunnelConfig { export interface TunnelConfig {
name: string; name: string;
tunnelType?: "local" | "remote";
sourceHostId: number; sourceHostId: number;
tunnelIndex: number; tunnelIndex: number;

View File

@@ -6,48 +6,7 @@ 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[];

View File

@@ -11,17 +11,12 @@ 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, logoutUser, isElectron } from "@/ui/main-axios.ts"; import { getUserInfo } 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);
@@ -34,12 +29,11 @@ function AppContent() {
const [transitionPhase, setTransitionPhase] = useState< const [transitionPhase, setTransitionPhase] = useState<
"idle" | "fadeOut" | "fadeIn" "idle" | "fadeOut" | "fadeIn"
>("idle"); >("idle");
const { currentTab, tabs, updateTab, addTab } = useTabs(); const { currentTab, tabs, updateTab } = 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" ||
@@ -51,49 +45,12 @@ 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);
@@ -129,52 +86,6 @@ 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);
@@ -220,6 +131,8 @@ 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;
@@ -250,6 +163,7 @@ 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()) {
@@ -274,12 +188,11 @@ 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 && !dbConnectionFailed) { if (authLoading) {
return ( return (
<div <div
className="fixed inset-0 flex items-center justify-center" className="h-screen w-screen flex items-center justify-center"
style={{ style={{
background: "var(--bg-elevated)", background: "var(--bg-elevated)",
backgroundImage: `repeating-linear-gradient( backgroundImage: `repeating-linear-gradient(
@@ -291,44 +204,13 @@ function AppContent() {
)`, )`,
}} }}
> >
<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="text-center">
<div className="flex items-center justify-center h-32"> <div className="w-16 h-16 border-4 border-primary/30 border-t-primary rounded-full animate-spin mx-auto" />
<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
@@ -338,6 +220,7 @@ 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}
@@ -348,6 +231,7 @@ function AppContent() {
{isAuthenticated && ( {isAuthenticated && (
<LeftSidebar <LeftSidebar
onSelectView={handleSelectView}
disabled={!isAuthenticated || authLoading} disabled={!isAuthenticated || authLoading}
isAdmin={isAdmin} isAdmin={isAdmin}
username={username} username={username}
@@ -367,6 +251,7 @@ 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}
@@ -380,6 +265,7 @@ 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}
@@ -412,16 +298,6 @@ 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}

View File

@@ -1,12 +0,0 @@
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;

View File

@@ -1,12 +0,0 @@
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;

View File

@@ -15,7 +15,6 @@ import {
getAdminOIDCConfig, getAdminOIDCConfig,
getRegistrationAllowed, getRegistrationAllowed,
getPasswordLoginAllowed, getPasswordLoginAllowed,
getPasswordResetAllowed,
getUserList, getUserList,
getUserInfo, getUserInfo,
isElectron, isElectron,
@@ -49,7 +48,6 @@ 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: "",
@@ -195,28 +193,6 @@ 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 })
@@ -391,8 +367,6 @@ 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>

View File

@@ -6,7 +6,6 @@ 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 {
@@ -14,8 +13,6 @@ 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;
@@ -30,8 +27,6 @@ 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();
@@ -39,7 +34,6 @@ 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);
@@ -102,16 +96,6 @@ 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>
@@ -136,19 +120,6 @@ 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>
); );
} }

View File

@@ -37,7 +37,6 @@ 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;
@@ -365,90 +364,19 @@ export function CommandPalette({
}} }}
className="flex items-center justify-between" className="flex items-center justify-between"
> >
<div className="flex items-center gap-2 flex-1 min-w-0"> <div className="flex items-center gap-2">
<Server className="h-4 w-4 flex-shrink-0" /> <Server className="h-4 w-4" />
<span className="truncate">{title}</span> <span>{title}</span>
</div> </div>
<ButtonGroup <div
className="flex-shrink-0" className="flex items-center gap-1"
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 rounded-l-none border-l-0" className="!px-2 h-7 border-1 border-edge"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<EllipsisVertical className="h-3 w-3" /> <EllipsisVertical className="h-3 w-3" />
@@ -459,82 +387,62 @@ 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"
> >
{host.enableTerminal && {shouldShowMetrics && (
!(host.showTerminalInSidebar ?? true) && ( <DropdownMenuItem
<DropdownMenuItem onClick={(e) => {
onClick={(e) => { e.stopPropagation();
e.stopPropagation(); handleHostServerDetailsClick(host);
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" />
<Terminal className="h-4 w-4" /> <span className="flex-1">
<span className="flex-1"> {t("hosts.openServerStats")}
{t("hosts.openTerminal")} </span>
</span> </DropdownMenuItem>
</DropdownMenuItem> )}
)} {host.enableFileManager && (
{shouldShowMetrics && <DropdownMenuItem
!(host.showServerStatsInSidebar ?? false) && ( onClick={(e) => {
<DropdownMenuItem e.stopPropagation();
onClick={(e) => { handleHostFileManagerClick(host);
e.stopPropagation(); }}
handleHostServerDetailsClick(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" <FolderOpen className="h-4 w-4" />
> <span className="flex-1">
<Server className="h-4 w-4" /> {t("hosts.openFileManager")}
<span className="flex-1"> </span>
{t("hosts.openServerStats")} </DropdownMenuItem>
</span> )}
</DropdownMenuItem> {host.enableTunnel && hasTunnelConnections && (
)} <DropdownMenuItem
{host.enableFileManager && onClick={(e) => {
!(host.showFileManagerInSidebar ?? false) && ( e.stopPropagation();
<DropdownMenuItem handleHostTunnelClick(host);
onClick={(e) => { }}
e.stopPropagation(); className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
handleHostFileManagerClick(host); >
}} <ArrowDownUp 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.openTunnels")}
<FolderOpen className="h-4 w-4" /> </span>
<span className="flex-1"> </DropdownMenuItem>
{t("hosts.openFileManager")} )}
</span> {host.enableDocker && (
</DropdownMenuItem> <DropdownMenuItem
)} onClick={(e) => {
{host.enableTunnel && e.stopPropagation();
hasTunnelConnections && handleHostDockerClick(host);
!(host.showTunnelInSidebar ?? false) && ( }}
<DropdownMenuItem className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
onClick={(e) => { >
e.stopPropagation(); <Container className="h-4 w-4" />
handleHostTunnelClick(host); <span className="flex-1">
}} {t("hosts.openDocker")}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary" </span>
> </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();
@@ -547,7 +455,7 @@ export function CommandPalette({
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</ButtonGroup> </div>
</CommandItem> </CommandItem>
); );
})} })}

View File

@@ -1,5 +1,6 @@
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 {
@@ -9,6 +10,7 @@ import {
getUptime, getUptime,
getVersionInfo, getVersionInfo,
getSSHHosts, getSSHHosts,
getTunnelStatuses,
getCredentials, getCredentials,
getRecentActivity, getRecentActivity,
resetRecentActivity, resetRecentActivity,
@@ -18,16 +20,29 @@ 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 } from "@/components/ui/kbd"; import { Kbd, KbdGroup } 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;
@@ -41,7 +56,6 @@ interface DashboardProps {
isTopbarOpen: boolean; isTopbarOpen: boolean;
rightSidebarOpen?: boolean; rightSidebarOpen?: boolean;
rightSidebarWidth?: number; rightSidebarWidth?: number;
initialDbError?: string | null;
} }
export function Dashboard({ export function Dashboard({
@@ -49,16 +63,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>(initialDbError); const [dbError, setDbError] = useState<string | null>(null);
const [uptime, setUptime] = useState<string>("0d 0h 0m"); const [uptime, setUptime] = useState<string>("0d 0h 0m");
const [versionStatus, setVersionStatus] = useState< const [versionStatus, setVersionStatus] = useState<
@@ -78,15 +92,8 @@ 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 {
@@ -152,18 +159,9 @@ export function Dashboard({
const uptimeInfo = await getUptime(); const uptimeInfo = await getUptime();
setUptime(uptimeInfo.formatted); setUptime(uptimeInfo.formatted);
const updateCheckDisabled = const versionInfo = await getVersionInfo();
localStorage.getItem("disableUpdateCheck") === "true"; setVersionText(`v${versionInfo.localVersion}`);
if (!updateCheckDisabled) { setVersionStatus(versionInfo.status || "up_to_date");
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();
@@ -426,18 +424,8 @@ 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="flex flex-row items-center gap-3"> <div className="text-2xl text-foreground font-semibold shrink-0">
<div className="text-2xl text-foreground font-semibold shrink-0"> {t("dashboard.title")}
{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">
@@ -496,91 +484,361 @@ 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">
{!preferencesLoading && layout && ( <div className="flex flex-row flex-1 gap-4 min-h-0 min-w-0">
<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">
className="grid gap-4 flex-1 min-h-0 auto-rows-fr" <div className="flex flex-col mx-3 my-2 overflow-y-auto overflow-x-hidden thin-scrollbar">
style={{ <p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
gridTemplateColumns: `repeat(${layout.gridColumns}, minmax(0, 1fr))`, <Server className="mr-3" />
}} {t("dashboard.serverOverview")}
> </p>
{layout.cards <div className="bg-canvas w-full h-auto border-2 border-edge rounded-md px-3 py-3">
.filter((card) => card.enabled) <div className="flex flex-row items-center justify-between mb-3 min-w-0 gap-2">
.sort((a, b) => a.order - b.order) <div className="flex flex-row items-center min-w-0">
.map((card) => { <History size={20} className="shrink-0" />
if (card.id === "server_overview") { <p className="ml-2 leading-none truncate">
return ( {t("dashboard.version")}
<ServerOverviewCard </p>
key={card.id} </div>
loggedIn={loggedIn}
versionText={versionText} <div className="flex flex-row items-center">
versionStatus={versionStatus} <p className="leading-none text-muted-foreground">
uptime={uptime} {versionText}
dbHealth={dbHealth} </p>
totalServers={totalServers} <Button
totalTunnels={totalTunnels} variant="outline"
totalCredentials={totalCredentials} size="sm"
/> className={`ml-2 text-sm border-1 border-edge ${versionStatus === "up_to_date" ? "text-green-400" : "text-yellow-400"}`}
); >
} else if (card.id === "recent_activity") { {versionStatus === "up_to_date"
return ( ? t("dashboard.upToDate")
<RecentActivityCard : t("dashboard.updateAvailable")}
key={card.id} </Button>
activities={recentActivity} <UpdateLog loggedIn={loggedIn} />
loading={recentActivityLoading} </div>
onReset={handleResetActivity} </div>
onActivityClick={handleActivityClick}
/> <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">
} else if (card.id === "network_graph") { <Clock size={20} className="shrink-0" />
return ( <p className="ml-2 leading-none truncate">
<NetworkGraphCard {t("dashboard.uptime")}
key={card.id} </p>
isTopbarOpen={isTopbarOpen} </div>
rightSidebarOpen={rightSidebarOpen}
rightSidebarWidth={rightSidebarWidth} <div className="flex flex-row items-center">
/> <p className="leading-none text-muted-foreground">
); {uptime}
} else if (card.id === "quick_actions") { </p>
return ( </div>
<QuickActionsCard </div>
key={card.id}
isAdmin={isAdmin} <div className="flex flex-row items-center justify-between min-w-0 gap-2">
onAddHost={handleAddHost} <div className="flex flex-row items-center min-w-0">
onAddCredential={handleAddCredential} <Database size={20} className="shrink-0" />
onOpenAdminSettings={handleOpenAdminSettings} <p className="ml-2 leading-none truncate">
onOpenUserProfile={handleOpenUserProfile} {t("dashboard.database")}
/> </p>
); </div>
} else if (card.id === "server_stats") {
return ( <div className="flex flex-row items-center">
<ServerStatsCard <p
key={card.id} className={`leading-none ${dbHealth === "healthy" ? "text-green-400" : "text-red-400"}`}
serverStats={serverStats} >
loading={serverStatsLoading} {dbHealth === "healthy"
onServerClick={handleServerStatClick} ? t("dashboard.healthy")
/> : t("dashboard.error")}
); </p>
} </div>
return null; </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>
)} <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"
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 !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"
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 !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"
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>
<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}
/>
)}
</> </>
); );
} }

View File

@@ -4,7 +4,6 @@ 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;
@@ -54,6 +53,7 @@ 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 {

File diff suppressed because it is too large Load Diff

View File

@@ -1,141 +0,0 @@
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>
);
}

View File

@@ -1,97 +0,0 @@
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>
);
}

View File

@@ -1,142 +0,0 @@
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>
);
}

View File

@@ -1,80 +0,0 @@
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>
);
}

View File

@@ -1,164 +0,0 @@
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>
);
}

View File

@@ -1,76 +0,0 @@
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,
};
}

View File

@@ -18,7 +18,6 @@ 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";
@@ -122,6 +121,7 @@ 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,6 +138,7 @@ 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) {

View File

@@ -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 min-w-0"> <CardTitle className="text-base font-semibold truncate flex-1">
{container.name.startsWith("/") {container.name.startsWith("/")
? container.name.slice(1) ? container.name.slice(1)
: container.name} : container.name}

View File

@@ -21,7 +21,6 @@ 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,
@@ -58,7 +57,6 @@ 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";
@@ -165,13 +163,6 @@ 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({
@@ -401,14 +392,14 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
} }
const loadDirectory = useCallback( const loadDirectory = useCallback(
async (path: string): Promise<boolean> => { async (path: string) => {
if (!sshSessionId) { if (!sshSessionId) {
console.error("Cannot load directory: no SSH session ID"); console.error("Cannot load directory: no SSH session ID");
return false; return;
} }
if (isLoading && currentLoadingPathRef.current !== path) { if (isLoading && currentLoadingPathRef.current !== path) {
return false; return;
} }
currentLoadingPathRef.current = path; currentLoadingPathRef.current = path;
@@ -420,7 +411,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 false; return;
} }
const files = Array.isArray(response) const files = Array.isArray(response)
@@ -429,63 +420,29 @@ 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") + ": " + errorMessage, t("fileManager.failedToLoadDirectory") +
": " +
(error.message || error),
); );
} }
if ( if (
errorMessage?.includes("connection") || error.message?.includes("connection") ||
errorMessage?.includes("SSH") error.message?.includes("SSH")
) { ) {
handleCloseWithError( handleCloseWithError(
t("fileManager.failedToLoadDirectory") + ": " + errorMessage, t("fileManager.failedToLoadDirectory") +
": " +
(error.message || error),
); );
} }
} }
return false;
} finally { } finally {
if (currentLoadingPathRef.current === path) { if (currentLoadingPathRef.current === path) {
setIsLoading(false); setIsLoading(false);
@@ -493,7 +450,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
} }
} }
}, },
[sshSessionId, isLoading, clearSelection, t, sudoDialogOpen], [sshSessionId, isLoading, clearSelection, t],
); );
const debouncedLoadDirectory = useCallback( const debouncedLoadDirectory = useCallback(
@@ -763,18 +720,9 @@ 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 (
axiosError.message?.includes("connection") || error.message?.includes("connection") ||
axiosError.message?.includes("established") error.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})`,
@@ -789,60 +737,6 @@ 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"),
@@ -2279,15 +2173,6 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
}} }}
onSave={handleSavePermissions} onSave={handleSavePermissions}
/> />
<SudoPasswordDialog
open={sudoDialogOpen}
onOpenChange={(open) => {
setSudoDialogOpen(open);
if (!open) setPendingSudoOperation(null);
}}
onSubmit={handleSudoPasswordSubmit}
/>
</div> </div>
); );
} }

View File

@@ -1,90 +0,0 @@
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>
);
}

View File

@@ -332,21 +332,17 @@ 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() || "";
const mimeTypes: Record<string, string> = { if (ext === "svg") {
svg: "image/svg+xml", try {
png: "image/png", const base64 = btoa(unescape(encodeURIComponent(content)));
jpg: "image/jpeg", return `data:image/svg+xml;base64,${base64}`;
jpeg: "image/jpeg", } catch (e) {
gif: "image/gif", console.error("Failed to encode SVG:", e);
webp: "image/webp", return "";
bmp: "image/bmp", }
ico: "image/x-icon", }
tiff: "image/tiff",
tif: "image/tiff",
};
const mimeType = mimeTypes[ext] || "image/png"; return `data:image/*;base64,${content}`;
return `data:${mimeType};base64,${content}`;
}; };
const WARNING_SIZE = 50 * 1024 * 1024; const WARNING_SIZE = 50 * 1024 * 1024;

View File

@@ -47,22 +47,6 @@ 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,
@@ -122,19 +106,7 @@ export function FileWindow({
await ensureSSHConnection(); await ensureSSHConnection();
const response = await readSSHFile(sshSessionId, file.path); const response = await readSSHFile(sshSessionId, file.path);
let fileContent = response.content || ""; const 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);

View File

@@ -11,8 +11,6 @@ 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";
@@ -33,8 +31,6 @@ 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";
@@ -149,6 +145,7 @@ 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);
@@ -267,16 +264,6 @@ 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;
} }
@@ -286,6 +273,7 @@ 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) {
@@ -302,6 +290,7 @@ 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) {

View File

@@ -1,213 +0,0 @@
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>
);
}

View File

@@ -1,98 +0,0 @@
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