97 Commits

Author SHA1 Message Date
Karmaa
26c1cacc9d Merge pull request #138 from LukeGus/dev-1.4.0
Dev 1.4.0
2025-09-01 00:11:26 -05:00
LukeGus
8dddbaa86d Read me update 2025-09-01 00:00:26 -05:00
LukeGus
d46fafb421 Update status refreshing to have a better interval and updated sidebar UI 2025-08-31 23:58:00 -05:00
LukeGus
25178928a0 Update status refreshing to have a better interval 2025-08-31 23:41:05 -05:00
LukeGus
2fe9c0f854 Update version 2025-08-31 20:27:17 -05:00
LukeGus
2f68dc018e Add SSH password reset, fix TOTP errors, and update json-import guide. 2025-08-31 20:18:08 -05:00
LukeGus
8b8e77214c Rename apps folder 2025-08-31 19:26:38 -05:00
LukeGus
89589dcf9f Update repo info and grammer 2025-08-31 19:25:44 -05:00
LukeGus
250ad975d4 Fix contributing md 2025-08-31 00:47:16 -05:00
LukeGus
b649e73c80 Fix contributing md 2025-08-31 00:43:23 -05:00
LukeGus
839e36adb9 Update TOTP Pr, begin password reset, add openapi.json for clarity. 2025-08-31 00:42:50 -05:00
Karmaa
f02c0c3163 Merge pull request #122 from rodrigopolo/main
SSH connection timeout and TOTP integration
2025-08-29 23:24:27 -05:00
Rodrigo Polo
83c41751ea Implementation of TOTP (Time-based One-Time Password) authentication 2025-08-29 20:29:33 -06:00
Rodrigo Polo
8058ffd217 Timeout issue 2025-08-29 01:47:08 -06:00
LukeGus
a3db62b0f8 Merge remote-tracking branch 'origin/main' 2025-08-28 01:25:31 -05:00
LukeGus
dc43bf1329 Update update log to include full release description 2025-08-28 01:25:23 -05:00
Karmaa
0a7b1b2bd0 Merge pull request #114 from LukeGus/dev-1.3.1
Dev 1.3.1
2025-08-28 01:00:47 -05:00
LukeGus
94e6617638 Update Terminal to use new isDev var 2025-08-28 00:59:20 -05:00
LukeGus
76995dbc1b Fix nginx error 2025-08-28 00:18:03 -05:00
LukeGus
be6cda7d8a Improve SSH stability and reconnection 2025-08-28 00:05:27 -05:00
LukeGus
9130eb68a8 Improve server stats and tunnel stability 2025-08-27 22:58:08 -05:00
LukeGus
200428498f Fix build error 2025-08-27 22:24:49 -05:00
LukeGus
f60c9c72aa Improve File Manger UI scaling 2025-08-27 22:20:39 -05:00
LukeGus
487919cedc Improve File Manger UI scaling, fix file manager disconnect, disable more than one file manager at a time. 2025-08-27 22:17:28 -05:00
LukeGus
b046aedcee Improve auth page 2025-08-27 16:07:11 -05:00
LukeGus
0c5216933a Update env 2025-08-27 15:34:12 -05:00
LukeGus
191dc8ba24 Hide password 2025-08-27 15:23:42 -05:00
LukeGus
d88c890ba7 Reduce automatic pinging 2025-08-27 11:34:38 -05:00
LukeGus
a34c60947d Finish migration into main-axios 2025-08-27 11:24:17 -05:00
LukeGus
56fddb6fcb Merge remote-tracking branch 'origin/dev-1.3.1' into dev-1.3.1 2025-08-25 18:00:51 -05:00
LukeGus
b9bd00f86e Migrate everytihng into the main-axios and update the routing to fix localhost issues. 2025-08-25 18:00:34 -05:00
Karmaa
d1ae7f659d Merge pull request #89 from LukeGus/dependabot/npm_and_yarn/dev-minor-updates-e674c81da0
Bump the dev-minor-updates group with 6 updates
2025-08-24 16:26:20 -05:00
Karmaa
b127c5ff4f Merge pull request #90 from LukeGus/dependabot/npm_and_yarn/prod-patch-updates-dc9bb06971
Bump the prod-patch-updates group with 19 updates
2025-08-24 16:26:00 -05:00
LukeGus
75a87400eb Merge remote-tracking branch 'origin/dev-1.3.1' into dependabot/npm_and_yarn/prod-patch-updates-dc9bb06971
# Conflicts:
#	package-lock.json
#	package.json
2025-08-24 16:25:13 -05:00
Karmaa
d4dccdb574 Merge pull request #88 from LukeGus/dependabot/npm_and_yarn/dev-patch-updates-7ca590a96b
Bump tw-animate-css from 1.3.5 to 1.3.7 in the dev-patch-updates group
2025-08-24 16:19:14 -05:00
Karmaa
a34421f5b2 Merge pull request #87 from LukeGus/dependabot/docker/docker/node-24-alpine
Bump node from 22-alpine to 24-alpine in /docker
2025-08-24 16:18:51 -05:00
Karmaa
7a5cf94d45 Merge pull request #86 from LukeGus/dependabot/github_actions/actions/cache-4
Bump actions/cache from 3 to 4
2025-08-24 16:18:33 -05:00
Karmaa
134f58b38b Merge pull request #85 from LukeGus/dependabot/github_actions/actions/checkout-5
Bump actions/checkout from 4 to 5
2025-08-24 16:18:15 -05:00
Karmaa
84dcda9363 Merge pull request #84 from LukeGus/dependabot/github_actions/docker/build-push-action-6
Bump docker/build-push-action from 5 to 6
2025-08-24 16:17:55 -05:00
Karmaa
d81cf20f5e Bump the prod-minor-updates group with 10 updates (#91)
Bumps the prod-minor-updates group with 10 updates:

| Package | From | To |
| --- | --- | --- |
| [@hookform/resolvers](https://github.com/react-hook-form/resolvers) | `5.1.1` | `5.2.1` |
| [@uiw/codemirror-extensions-hyper-link](https://github.com/uiwjs/react-codemirror) | `4.24.1` | `4.25.1` |
| [@uiw/codemirror-extensions-langs](https://github.com/uiwjs/react-codemirror) | `4.24.1` | `4.25.1` |
| [@uiw/codemirror-themes](https://github.com/uiwjs/react-codemirror) | `4.24.1` | `4.25.1` |
| [@uiw/react-codemirror](https://github.com/uiwjs/react-codemirror) | `4.24.1` | `4.25.1` |
| [axios](https://github.com/axios/axios) | `1.10.0` | `1.11.0` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `0.525.0` | `0.541.0` |
| [react-hook-form](https://github.com/react-hook-form/react-hook-form) | `7.60.0` | `7.62.0` |
| [ssh2](https://github.com/mscdex/ssh2) | `1.16.0` | `1.17.0` |
| [zod](https://github.com/colinhacks/zod) | `4.0.5` | `4.1.1` |


Updates `@hookform/resolvers` from 5.1.1 to 5.2.1
- [Release notes](https://github.com/react-hook-form/resolvers/releases)
- [Commits](https://github.com/react-hook-form/resolvers/compare/v5.1.1...v5.2.1)

Updates `@uiw/codemirror-extensions-hyper-link` from 4.24.1 to 4.25.1
- [Release notes](https://github.com/uiwjs/react-codemirror/releases)
- [Commits](https://github.com/uiwjs/react-codemirror/compare/v4.24.1...v4.25.1)

Updates `@uiw/codemirror-extensions-langs` from 4.24.1 to 4.25.1
- [Release notes](https://github.com/uiwjs/react-codemirror/releases)
- [Commits](https://github.com/uiwjs/react-codemirror/compare/v4.24.1...v4.25.1)

Updates `@uiw/codemirror-themes` from 4.24.1 to 4.25.1
- [Release notes](https://github.com/uiwjs/react-codemirror/releases)
- [Commits](https://github.com/uiwjs/react-codemirror/compare/v4.24.1...v4.25.1)

Updates `@uiw/react-codemirror` from 4.24.1 to 4.25.1
- [Release notes](https://github.com/uiwjs/react-codemirror/releases)
- [Commits](https://github.com/uiwjs/react-codemirror/compare/v4.24.1...v4.25.1)

Updates `axios` from 1.10.0 to 1.11.0
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.10.0...v1.11.0)

Updates `lucide-react` from 0.525.0 to 0.541.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/0.541.0/packages/lucide-react)

Updates `react-hook-form` from 7.60.0 to 7.62.0
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.60.0...v7.62.0)

Updates `ssh2` from 1.16.0 to 1.17.0
- [Commits](https://github.com/mscdex/ssh2/compare/v1.16.0...v1.17.0)

Updates `zod` from 4.0.5 to 4.1.1
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v4.0.5...v4.1.1)

---
updated-dependencies:
- dependency-name: "@hookform/resolvers"
  dependency-version: 5.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: "@uiw/codemirror-extensions-hyper-link"
  dependency-version: 4.25.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: "@uiw/codemirror-extensions-langs"
  dependency-version: 4.25.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: "@uiw/codemirror-themes"
  dependency-version: 4.25.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: "@uiw/react-codemirror"
  dependency-version: 4.25.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: axios
  dependency-version: 1.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: lucide-react
  dependency-version: 0.541.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: react-hook-form
  dependency-version: 7.62.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: ssh2
  dependency-version: 1.17.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: zod
  dependency-version: 4.1.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-24 16:16:41 -05:00
Karmaa
86fd8574f1 Bump @vitejs/plugin-react-swc from 3.10.2 to 4.0.1 (#92)
* Update README.md

* Bump @vitejs/plugin-react-swc from 3.10.2 to 4.0.1

Bumps [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react-swc) from 3.10.2 to 4.0.1.
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@4.0.1/packages/plugin-react-swc)

---
updated-dependencies:
- dependency-name: "@vitejs/plugin-react-swc"
  dependency-version: 4.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Karmaa <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-24 16:16:20 -05:00
dependabot[bot]
6e175e2b36 Bump @vitejs/plugin-react-swc from 3.10.2 to 4.0.1
Bumps [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react-swc) from 3.10.2 to 4.0.1.
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@4.0.1/packages/plugin-react-swc)

---
updated-dependencies:
- dependency-name: "@vitejs/plugin-react-swc"
  dependency-version: 4.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-24 21:14:55 +00:00
dependabot[bot]
c7fba8dbb1 Bump the prod-minor-updates group with 10 updates
Bumps the prod-minor-updates group with 10 updates:

| Package | From | To |
| --- | --- | --- |
| [@hookform/resolvers](https://github.com/react-hook-form/resolvers) | `5.1.1` | `5.2.1` |
| [@uiw/codemirror-extensions-hyper-link](https://github.com/uiwjs/react-codemirror) | `4.24.1` | `4.25.1` |
| [@uiw/codemirror-extensions-langs](https://github.com/uiwjs/react-codemirror) | `4.24.1` | `4.25.1` |
| [@uiw/codemirror-themes](https://github.com/uiwjs/react-codemirror) | `4.24.1` | `4.25.1` |
| [@uiw/react-codemirror](https://github.com/uiwjs/react-codemirror) | `4.24.1` | `4.25.1` |
| [axios](https://github.com/axios/axios) | `1.10.0` | `1.11.0` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `0.525.0` | `0.541.0` |
| [react-hook-form](https://github.com/react-hook-form/react-hook-form) | `7.60.0` | `7.62.0` |
| [ssh2](https://github.com/mscdex/ssh2) | `1.16.0` | `1.17.0` |
| [zod](https://github.com/colinhacks/zod) | `4.0.5` | `4.1.1` |


Updates `@hookform/resolvers` from 5.1.1 to 5.2.1
- [Release notes](https://github.com/react-hook-form/resolvers/releases)
- [Commits](https://github.com/react-hook-form/resolvers/compare/v5.1.1...v5.2.1)

Updates `@uiw/codemirror-extensions-hyper-link` from 4.24.1 to 4.25.1
- [Release notes](https://github.com/uiwjs/react-codemirror/releases)
- [Commits](https://github.com/uiwjs/react-codemirror/compare/v4.24.1...v4.25.1)

Updates `@uiw/codemirror-extensions-langs` from 4.24.1 to 4.25.1
- [Release notes](https://github.com/uiwjs/react-codemirror/releases)
- [Commits](https://github.com/uiwjs/react-codemirror/compare/v4.24.1...v4.25.1)

Updates `@uiw/codemirror-themes` from 4.24.1 to 4.25.1
- [Release notes](https://github.com/uiwjs/react-codemirror/releases)
- [Commits](https://github.com/uiwjs/react-codemirror/compare/v4.24.1...v4.25.1)

Updates `@uiw/react-codemirror` from 4.24.1 to 4.25.1
- [Release notes](https://github.com/uiwjs/react-codemirror/releases)
- [Commits](https://github.com/uiwjs/react-codemirror/compare/v4.24.1...v4.25.1)

Updates `axios` from 1.10.0 to 1.11.0
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.10.0...v1.11.0)

Updates `lucide-react` from 0.525.0 to 0.541.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/0.541.0/packages/lucide-react)

Updates `react-hook-form` from 7.60.0 to 7.62.0
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.60.0...v7.62.0)

Updates `ssh2` from 1.16.0 to 1.17.0
- [Commits](https://github.com/mscdex/ssh2/compare/v1.16.0...v1.17.0)

Updates `zod` from 4.0.5 to 4.1.1
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v4.0.5...v4.1.1)

---
updated-dependencies:
- dependency-name: "@hookform/resolvers"
  dependency-version: 5.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: "@uiw/codemirror-extensions-hyper-link"
  dependency-version: 4.25.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: "@uiw/codemirror-extensions-langs"
  dependency-version: 4.25.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: "@uiw/codemirror-themes"
  dependency-version: 4.25.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: "@uiw/react-codemirror"
  dependency-version: 4.25.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: axios
  dependency-version: 1.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: lucide-react
  dependency-version: 0.541.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: react-hook-form
  dependency-version: 7.62.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: ssh2
  dependency-version: 1.17.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: zod
  dependency-version: 4.1.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-24 21:14:46 +00:00
dependabot[bot]
0c2ac176a7 Bump the prod-patch-updates group with 19 updates
Bumps the prod-patch-updates group with 19 updates:

| Package | From | To |
| --- | --- | --- |
| [@radix-ui/react-accordion](https://github.com/radix-ui/primitives) | `1.2.11` | `1.2.12` |
| [@radix-ui/react-checkbox](https://github.com/radix-ui/primitives) | `1.3.2` | `1.3.3` |
| [@radix-ui/react-collapsible](https://github.com/radix-ui/primitives) | `1.1.11` | `1.1.12` |
| [@radix-ui/react-dropdown-menu](https://github.com/radix-ui/primitives) | `2.1.15` | `2.1.16` |
| [@radix-ui/react-popover](https://github.com/radix-ui/primitives) | `1.1.14` | `1.1.15` |
| [@radix-ui/react-scroll-area](https://github.com/radix-ui/primitives) | `1.2.9` | `1.2.10` |
| [@radix-ui/react-select](https://github.com/radix-ui/primitives) | `2.2.5` | `2.2.6` |
| [@radix-ui/react-slider](https://github.com/radix-ui/primitives) | `1.3.5` | `1.3.6` |
| [@radix-ui/react-switch](https://github.com/radix-ui/primitives) | `1.2.5` | `1.2.6` |
| [@radix-ui/react-tabs](https://github.com/radix-ui/primitives) | `1.1.12` | `1.1.13` |
| [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.1.11` | `4.1.12` |
| [dotenv](https://github.com/motdotla/dotenv) | `17.2.0` | `17.2.1` |
| [drizzle-orm](https://github.com/drizzle-team/drizzle-orm) | `0.44.3` | `0.44.4` |
| [react](https://github.com/facebook/react/tree/HEAD/packages/react) | `19.1.0` | `19.1.1` |
| [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) | `19.1.8` | `19.1.11` |
| [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) | `19.1.0` | `19.1.1` |
| [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom) | `19.1.6` | `19.1.7` |
| [react-resizable-panels](https://github.com/bvaughn/react-resizable-panels) | `3.0.3` | `3.0.5` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.1.11` | `4.1.12` |


Updates `@radix-ui/react-accordion` from 1.2.11 to 1.2.12
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-checkbox` from 1.3.2 to 1.3.3
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-collapsible` from 1.1.11 to 1.1.12
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-dropdown-menu` from 2.1.15 to 2.1.16
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-popover` from 1.1.14 to 1.1.15
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-scroll-area` from 1.2.9 to 1.2.10
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-select` from 2.2.5 to 2.2.6
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-slider` from 1.3.5 to 1.3.6
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-switch` from 1.2.5 to 1.2.6
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-tabs` from 1.1.12 to 1.1.13
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@tailwindcss/vite` from 4.1.11 to 4.1.12
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.12/packages/@tailwindcss-vite)

Updates `dotenv` from 17.2.0 to 17.2.1
- [Changelog](https://github.com/motdotla/dotenv/blob/master/CHANGELOG.md)
- [Commits](https://github.com/motdotla/dotenv/compare/v17.2.0...v17.2.1)

Updates `drizzle-orm` from 0.44.3 to 0.44.4
- [Release notes](https://github.com/drizzle-team/drizzle-orm/releases)
- [Commits](https://github.com/drizzle-team/drizzle-orm/compare/0.44.3...0.44.4)

Updates `react` from 19.1.0 to 19.1.1
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.1.1/packages/react)

Updates `@types/react` from 19.1.8 to 19.1.11
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

Updates `react-dom` from 19.1.0 to 19.1.1
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.1.1/packages/react-dom)

Updates `@types/react-dom` from 19.1.6 to 19.1.7
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

Updates `react-resizable-panels` from 3.0.3 to 3.0.5
- [Release notes](https://github.com/bvaughn/react-resizable-panels/releases)
- [Commits](https://github.com/bvaughn/react-resizable-panels/compare/3.0.3...3.0.5)

Updates `tailwindcss` from 4.1.11 to 4.1.12
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.12/packages/tailwindcss)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-accordion"
  dependency-version: 1.2.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: "@radix-ui/react-checkbox"
  dependency-version: 1.3.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: "@radix-ui/react-collapsible"
  dependency-version: 1.1.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: "@radix-ui/react-dropdown-menu"
  dependency-version: 2.1.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: "@radix-ui/react-popover"
  dependency-version: 1.1.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: "@radix-ui/react-scroll-area"
  dependency-version: 1.2.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: "@radix-ui/react-select"
  dependency-version: 2.2.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: "@radix-ui/react-slider"
  dependency-version: 1.3.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: "@radix-ui/react-switch"
  dependency-version: 1.2.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: "@radix-ui/react-tabs"
  dependency-version: 1.1.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.1.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: dotenv
  dependency-version: 17.2.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: drizzle-orm
  dependency-version: 0.44.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: react
  dependency-version: 19.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: "@types/react"
  dependency-version: 19.1.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: react-dom
  dependency-version: 19.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: "@types/react-dom"
  dependency-version: 19.1.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: react-resizable-panels
  dependency-version: 3.0.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: tailwindcss
  dependency-version: 4.1.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-24 21:13:27 +00:00
dependabot[bot]
59a38dad0a Bump the dev-minor-updates group with 6 updates
Bumps the dev-minor-updates group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) | `9.31.0` | `9.34.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `24.0.13` | `24.3.0` |
| [eslint](https://github.com/eslint/eslint) | `9.31.0` | `9.34.0` |
| [typescript](https://github.com/microsoft/TypeScript) | `5.8.3` | `5.9.2` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.37.0` | `8.40.0` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `7.0.4` | `7.1.3` |


Updates `@eslint/js` from 9.31.0 to 9.34.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/commits/v9.34.0/packages/js)

Updates `@types/node` from 24.0.13 to 24.3.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `eslint` from 9.31.0 to 9.34.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v9.31.0...v9.34.0)

Updates `typescript` from 5.8.3 to 5.9.2
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release-publish.yml)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.8.3...v5.9.2)

Updates `typescript-eslint` from 8.37.0 to 8.40.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.40.0/packages/typescript-eslint)

Updates `vite` from 7.0.4 to 7.1.3
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.1.3/packages/vite)

---
updated-dependencies:
- dependency-name: "@eslint/js"
  dependency-version: 9.34.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: "@types/node"
  dependency-version: 24.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: eslint
  dependency-version: 9.34.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: typescript
  dependency-version: 5.9.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: typescript-eslint
  dependency-version: 8.40.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: vite
  dependency-version: 7.1.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-24 21:10:31 +00:00
dependabot[bot]
6ac536ebad Bump tw-animate-css from 1.3.5 to 1.3.7 in the dev-patch-updates group
Bumps the dev-patch-updates group with 1 update: [tw-animate-css](https://github.com/Wombosvideo/tw-animate-css).


Updates `tw-animate-css` from 1.3.5 to 1.3.7
- [Release notes](https://github.com/Wombosvideo/tw-animate-css/releases)
- [Commits](https://github.com/Wombosvideo/tw-animate-css/compare/v1.3.5...v1.3.7)

---
updated-dependencies:
- dependency-name: tw-animate-css
  dependency-version: 1.3.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-24 21:09:29 +00:00
dependabot[bot]
12418eb5b2 Bump node from 22-alpine to 24-alpine in /docker
Bumps node from 22-alpine to 24-alpine.

---
updated-dependencies:
- dependency-name: node
  dependency-version: 24-alpine
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-24 21:08:59 +00:00
dependabot[bot]
e364e9b38e Bump actions/cache from 3 to 4
Bumps [actions/cache](https://github.com/actions/cache) from 3 to 4.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-24 21:08:53 +00:00
dependabot[bot]
402f9fa909 Bump actions/checkout from 4 to 5
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-24 21:08:51 +00:00
dependabot[bot]
5ac25e2a26 Bump docker/build-push-action from 5 to 6
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 5 to 6.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-24 21:08:49 +00:00
Karmaa
5fbac89e67 Update README.md 2025-08-24 15:39:53 -05:00
LukeGus
f2fb938e5f Clean up main-axios.ts 2025-08-24 01:28:18 -05:00
LukeGus
cef420d1d8 Clean up main-axios.ts 2025-08-24 01:27:41 -05:00
LukeGus
94f69cee3f Migrate to new websocket link for locahlost 2025-08-24 01:10:59 -05:00
LukeGus
23e72aedfd Migrate to new websocket link for locahlost 2025-08-24 00:59:39 -05:00
LukeGus
7957ed06e4 Fix localhost connection issues 2025-08-21 22:47:38 -05:00
Karmaa
5f1a2d9a17 Update README.md 2025-08-19 12:36:11 -05:00
Karmaa
dbe12ce681 Merge pull request #72 from LukeGus/dev-1.3
Dev 1.3
2025-08-19 00:13:56 -05:00
LukeGus
d66efdb74f Merge remote-tracking branch 'origin/main' into dev-1.3
# Conflicts:
#	.env
2025-08-19 00:13:15 -05:00
LukeGus
c3d1855c65 Readme update 2025-08-19 00:09:25 -05:00
LukeGus
4f4e5296a4 Readme update 2025-08-19 00:05:51 -05:00
LukeGus
61da21c507 Readme update 2025-08-18 23:59:48 -05:00
LukeGus
347e8c40df Readme and UI updates 2025-08-18 23:52:04 -05:00
LukeGus
f0ae53c41b Fix HDD name and UI spacing 2025-08-18 01:02:30 -05:00
LukeGus
53f9e888df Fix api 2025-08-18 00:38:38 -05:00
LukeGus
c1d06028c3 Format code 2025-08-18 00:13:21 -05:00
LukeGus
fa64e98ef9 Remove old terminal 2025-08-17 22:57:43 -05:00
LukeGus
2df2c4e73d Added config editor operations and re-added ssh tools with recording feature 2025-08-17 22:57:25 -05:00
LukeGus
7d904c4a2c FIx frontend old confige editor paths 2025-08-17 19:23:33 -05:00
LukeGus
cf945e3665 Fix database old config editor paths 2025-08-17 16:01:12 -05:00
LukeGus
880907cc93 Config editor rename to file manager + fixed up file manager UI 2025-08-17 15:57:46 -05:00
LukeGus
22162e5b9b Added config editor to tab system, (W.I.P) 2025-08-17 02:33:50 -05:00
LukeGus
981705e81d Made server.tsx work with ssh tunnels and server stats. Moved admin settings. 2025-08-17 01:56:47 -05:00
LukeGus
5445cb2b78 Added server.tsx and make it work with tab system 2025-08-16 01:30:18 -05:00
LukeGus
2667af9437 Added SSH manager and terminals to tab system, now I need to do the server stats 2025-08-16 00:29:40 -05:00
LukeGus
58947f4455 Added tab UI (initial) 2025-08-15 02:17:07 -05:00
LukeGus
b7f52d4d73 Margin updates 2025-08-15 01:27:31 -05:00
LukeGus
b854a4956c UI upadte, added host system, better flex scaling, improved login. 2025-08-15 01:01:04 -05:00
LukeGus
07367b24b6 Added navbar 2025-08-14 01:47:22 -05:00
LukeGus
1b076cc612 Build error fixes 2025-08-14 01:28:47 -05:00
LukeGus
81d1db09e4 Initial UI commit for 1.3 2025-08-14 01:24:05 -05:00
Karmaa
f62bb7f773 Update .env 2025-08-13 13:08:09 -05:00
Karmaa
0906ddfe6e Merge pull request #70 from LukeGus/dev-1.2
Dev 1.2
2025-08-13 12:01:30 -05:00
LukeGus
96864dbeb4 Fix admin tag format/styling 2025-08-13 11:59:50 -05:00
LukeGus
3a663f1b47 Add sepeator betwen json stuff and refresh button 2025-08-13 11:58:10 -05:00
LukeGus
b9d5965aa6 Fix nginx.conf for json import routing 2025-08-13 01:44:40 -05:00
LukeGus
47f8d3f23b Re add ssh.ts bulk add backend 2025-08-13 00:26:32 -05:00
LukeGus
c71b8b4211 Fix routing for json imports, added dynamic alerts. 2025-08-13 00:05:13 -05:00
LukeGus
07a8fc3e50 Add bulk import path 2025-08-12 14:38:01 -05:00
LukeGus
c90c45ec61 Merge remote-tracking branch 'origin/dev-1.2' into dev-1.2 2025-08-12 13:28:05 -05:00
LukeGus
4e93ac7d88 Update read me 2025-08-12 13:27:57 -05:00
Karmaa
9ce69a80d7 Merge pull request #68 from Lokowitz/dependabot
add Dependabot and update Node version in Dockerfile
2025-08-12 13:20:15 -05:00
LukeGus
602f21b475 Confirm and hide password, reset password, delete accounts, better admin page, json import hosts. 2025-08-12 13:15:08 -05:00
Marvin
3b347f7ae5 Create .nvmrc 2025-08-12 12:04:47 +02:00
Marvin
1f83fb68f0 Update Dockerfile 2025-08-12 12:04:04 +02:00
Marvin
a2481362a2 dependabot.yml aktualisieren 2025-08-11 22:31:57 +02:00
Marvin
f49c32d26f dependabot.yml erstellen 2025-08-11 22:10:40 +02:00
90 changed files with 15229 additions and 5671 deletions

2
.env
View File

@@ -1 +1 @@
VERSION=1.1.1
VERSION=1.4.0

40
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,40 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "daily"
groups:
dev-patch-updates:
dependency-type: "development"
update-types:
- "patch"
dev-minor-updates:
dependency-type: "development"
update-types:
- "minor"
prod-patch-updates:
dependency-type: "production"
update-types:
- "patch"
prod-minor-updates:
dependency-type: "production"
update-types:
- "minor"
- package-ecosystem: "docker"
directory: "/docker"
schedule:
interval: "daily"
groups:
patch-updates:
update-types:
- "patch"
minor-updates:
update-types:
- "minor"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"

View File

@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 1
@@ -37,7 +37,7 @@ jobs:
network=host
- name: Cache npm dependencies
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: |
~/.npm
@@ -48,7 +48,7 @@ jobs:
${{ runner.os }}-node-
- name: Cache Docker layers
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.ref_name }}-${{ hashFiles('docker/Dockerfile') }}
@@ -78,7 +78,7 @@ jobs:
echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV
- name: Build and Push Multi-Arch Docker Image
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/Dockerfile

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
22

56
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,56 @@
# Contributing
## Prerequisites
- [Node.js](https://nodejs.org/en/download/) (built with v24)
- [NPM](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm)
- [Git](https://git-scm.com/downloads)
## Installation
1. Clone the repository:
```sh
git clone https://github.com/LukeGus/Termix
```
2. Install the dependencies:
```sh
npm install
```
## Running the development server
Run the following commands:
```sh
npm run dev
npx tsc -p tsconfig.node.json
node ./dist/backend/starter.js
```
This will start the backend and the frontend Vite server. You can access Termix by going to `http://localhost:5174/`.
## Contributing
1. **Fork the repository**: Click the "Fork" button at the top right of
the [repository page](https://github.com/LukeGus/Termix).
2. **Create a new branch**:
```sh
git checkout -b feature/my-new-feature
```
3. **Make your changes**: Implement your feature, fix, or improvement.
4. **Commit your changes**:
```sh
git commit -m "Add feature: my new feature"
```
5. **Push to your fork**:
```sh
git push origin feature/my-new-feature
```
6. **Open a pull request**: Go to the original repository and create a PR with a clear description.
## 📝 Guidelines
- Follow the existing code style. Use Tailwind CSS with shadcn components.
- Place all API routes in the `main-axios.ts` file. Updating the `openapi.json` is unneeded.
- Include meaningful commit messages.
- Link related issues when applicable.

View File

@@ -13,36 +13,41 @@
[![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 />
<p align="center">
<a href="https://github.com/LukeGus/Termix">
<img alt="Termix Banner" src=./public/icon.svg style="width: 250px; height: auto;"> </a>
<img alt="Termix Banner" src=./repo-images/HeaderImage.png style="width: auto; height: auto;"> </a>
</p>
If you would like, you can support the project here!\
[![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus)
# Overview
Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides a web-based solution for managing your servers and infrastructure through a single, intuitive interface. Termix offers SSH terminal access, SSH tunneling capabilities, and remote file configuration editing, with many more tools to come.
<p align="center">
<a href="https://github.com/LukeGus/Termix">
<img alt="Termix Banner" src=./public/icon.svg style="width: 250px; height: 250px;"> </a>
</p>
Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides a web-based solution for managing your servers and infrastructure through a single, intuitive interface. Termix offers SSH terminal access, SSH tunneling capabilities, and remote file editing, with many more tools to come.
# Features
- **SSH Terminal Access** - Full-featured terminal with split-screen support (up to 4 panels) and tab system
- **SSH Tunnel Management** - Create and manage SSH tunnels with automatic reconnection and health monitoring
- **Remote Config Editor** - Edit files directly on remote servers with syntax highlighting and file management
- **Remote File Editor** - Edit files directly on remote servers with syntax highlighting, file management features (uploading, removing, renaming, deleting files)
- **SSH Host Manager** - Save, organize, and manage your SSH connections with tags and folders
- **User Authentication** - Secure user management with admin controls and OIDC support with more auth types planned
- **Modern UI** - Clean interface built with React, Tailwind CSS, and the amazing Shadcn
- **Server Stats** - View CPU, memory, and HDD usage on any SSH server
- **User Authentication** - Secure user management with admin controls and OIDC and 2FA (TOTP) support
- **Modern UI** - Clean interface built with React, Tailwind CSS, and Shadcn
# Planned Features
- **Improved Admin Control** - Ability to manage admins, and give more fine-grained control over their permissions, share hosts, reset passwords, delete accounts, etc
- **More auth types** - Add 2FA, TOTP, etc
- **Theming** - Modify themeing for all tools
- **Improved SFTP Support** - Ability to manage files easier with the config editor by uploading, creating, and removing files
- **Improved Admin Control** - Give more fine-grained control over user and admin permissions, share hosts, etc
- **Theming** - Modify theming for all tools
- **Improved Terminal Support** - Add more terminal protocols such as VNC and RDP (anyone who has experience in integrating RDP into a web-application similar to Apache Guacamole, please contact me by creating an issue)
- **Mobile Support** - Support a mobile app or version of the Termix website to manage servers from your phone
# Installation
Visit the Termix [Docs](https://docs.termix.site/docs) for more information on how to install Termix. Otherwise, view a sample docker-compose file here:
Visit the Termix [Docs](https://docs.termix.site/install) for more information on how to install Termix. Otherwise, view a sample docker-compose file here:
```yaml
services:
termix:
@@ -78,7 +83,7 @@ If you need help with Termix, you can join the [Discord](https://discord.gg/jVQG
</p>
<p align="center">
<video src="https://github.com/user-attachments/assets/0f95495d-c5db-48f5-b18b-9ab48bb10d31" width="800" controls>
<video src="https://github.com/user-attachments/assets/f9caa061-10dc-4173-ae7d-c6d42f05cf56" width="800" controls>
Your browser does not support the video tag.
</video>
</p>

View File

@@ -1,5 +1,5 @@
# Stage 1: Install dependencies and build frontend
FROM node:18-alpine AS deps
FROM node:24-alpine AS deps
WORKDIR /app
RUN apk add --no-cache python3 make g++
@@ -26,7 +26,7 @@ COPY . .
RUN npm run build:backend
# Stage 4: Production dependencies
FROM node:18-alpine AS production-deps
FROM node:24-alpine AS production-deps
WORKDIR /app
COPY package*.json ./
@@ -35,7 +35,7 @@ RUN npm ci --only=production --ignore-scripts --force && \
npm cache clean --force
# Stage 5: Build native modules
FROM node:18-alpine AS native-builder
FROM node:24-alpine AS native-builder
WORKDIR /app
RUN apk add --no-cache python3 make g++
@@ -46,7 +46,7 @@ RUN npm ci --only=production bcryptjs better-sqlite3 --force && \
npm cache clean --force
# Stage 6: Final image
FROM node:18-alpine
FROM node:24-alpine
ENV DATA_DIR=/app/data \
PORT=8080 \
NODE_ENV=production
@@ -72,8 +72,8 @@ RUN chown -R node:node /app
VOLUME ["/app/data"]
EXPOSE ${PORT} 8081 8082 8083 8084
EXPOSE ${PORT} 8081 8082 8083 8084 8085
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
CMD ["/entrypoint.sh"]
CMD ["/entrypoint.sh"]

View File

@@ -45,7 +45,16 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
location /ssh/db/ {
location /alerts/ {
proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /ssh/ {
proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1;
proxy_set_header Host $host;
@@ -62,6 +71,14 @@ http {
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_connect_timeout 75s;
proxy_set_header Connection "";
proxy_buffering off;
proxy_request_buffering off;
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;
@@ -76,7 +93,34 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
location /ssh/config_editor/ {
location /ssh/file_manager/recent {
proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /ssh/file_manager/pinned {
proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /ssh/file_manager/shortcuts {
proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /ssh/file_manager/ssh/ {
proxy_pass http://127.0.0.1:8084;
proxy_http_version 1.1;
proxy_set_header Host $host;
@@ -85,8 +129,17 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/ssh/config_editor/(recent|pinned|shortcuts) {
proxy_pass http://127.0.0.1:8081;
location /status/ {
proxy_pass http://127.0.0.1:8085;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /metrics/ {
proxy_pass http://127.0.0.1:8085;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;

2262
openapi.json Normal file

File diff suppressed because it is too large Load Diff

835
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,10 +17,11 @@
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
@@ -28,10 +29,12 @@
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.7",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.11",
"@types/bcryptjs": "^2.4.6",
"@types/multer": "^2.0.0",
"@types/qrcode": "^1.5.5",
"@types/speakeasy": "^2.0.10",
"@uiw/codemirror-extensions-hyper-link": "^4.24.1",
"@uiw/codemirror-extensions-langs": "^4.24.1",
"@uiw/codemirror-themes": "^4.24.1",
@@ -59,12 +62,16 @@
"lucide-react": "^0.525.0",
"multer": "^2.0.2",
"nanoid": "^5.1.5",
"next-themes": "^0.4.6",
"node-fetch": "^3.3.2",
"qrcode": "^1.5.4",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.60.0",
"react-resizable-panels": "^3.0.3",
"react-xtermjs": "^1.0.10",
"sonner": "^2.0.7",
"speakeasy": "^2.0.0",
"ssh2": "^1.16.0",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11",
@@ -73,26 +80,26 @@
"zod": "^4.0.5"
},
"devDependencies": {
"@eslint/js": "^9.30.1",
"@eslint/js": "^9.34.0",
"@types/better-sqlite3": "^7.6.13",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.0.13",
"@types/node": "^24.3.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/ssh2": "^1.15.5",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react-swc": "^3.10.2",
"autoprefixer": "^10.4.21",
"eslint": "^9.30.1",
"eslint": "^9.34.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"ts-node": "^10.9.2",
"tw-animate-css": "^1.3.5",
"typescript": "~5.8.3",
"typescript-eslint": "^8.35.1",
"vite": "^7.0.4"
"typescript": "~5.9.2",
"typescript-eslint": "^8.40.0",
"vite": "^7.1.3"
}
}

BIN
repo-images/HeaderImage.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 614 KiB

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 397 KiB

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 115 KiB

View File

@@ -1,14 +1,70 @@
import React from "react"
import React, {useState, useEffect} from "react"
import {LeftSidebar} from "@/ui/Navigation/LeftSidebar.tsx"
import {Homepage} from "@/ui/Homepage/Homepage.tsx"
import {AppView} from "@/ui/Navigation/AppView.tsx"
import {HostManager} from "@/ui/Apps/Host Manager/HostManager.tsx"
import {TabProvider, useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx"
import {TopNavbar} from "@/ui/Navigation/TopNavbar.tsx";
import { AdminSettings } from "@/ui/Admin/AdminSettings";
import { UserProfile } from "@/ui/User/UserProfile.tsx";
import { Toaster } from "@/components/ui/sonner";
import { getUserInfo } from "@/ui/main-axios.ts";
import {Homepage} from "@/apps/Homepage/Homepage.tsx"
import {Terminal} from "@/apps/SSH/Terminal/Terminal.tsx"
import {SSHTunnel} from "@/apps/SSH/Tunnel/SSHTunnel.tsx";
import {ConfigEditor} from "@/apps/SSH/Config Editor/ConfigEditor.tsx";
import {SSHManager} from "@/apps/SSH/Manager/SSHManager.tsx"
function getCookie(name: string) {
return document.cookie.split('; ').reduce((r, v) => {
const parts = v.split('=');
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
}, "");
}
function App() {
const [view, setView] = React.useState<string>("homepage")
const [mountedViews, setMountedViews] = React.useState<Set<string>>(new Set(["homepage"]))
function setCookie(name: string, value: string, days = 7) {
const expires = new Date(Date.now() + days * 864e5).toUTCString();
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
}
function AppContent() {
const [view, setView] = useState<string>("homepage")
const [mountedViews, setMountedViews] = useState<Set<string>>(new Set(["homepage"]))
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [username, setUsername] = useState<string | null>(null)
const [isAdmin, setIsAdmin] = useState(false)
const [authLoading, setAuthLoading] = useState(true)
const [isTopbarOpen, setIsTopbarOpen] = useState<boolean>(true)
const {currentTab, tabs} = useTabs();
useEffect(() => {
const checkAuth = () => {
const jwt = getCookie("jwt");
if (jwt) {
setAuthLoading(true);
getUserInfo()
.then((meRes) => {
setIsAuthenticated(true);
setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null);
})
.catch((err) => {
setIsAuthenticated(false);
setIsAdmin(false);
setUsername(null);
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
})
.finally(() => setAuthLoading(false));
} else {
setIsAuthenticated(false);
setIsAdmin(false);
setUsername(null);
setAuthLoading(false);
}
}
checkAuth()
const handleStorageChange = () => checkAuth()
window.addEventListener('storage', handleStorageChange)
return () => window.removeEventListener('storage', handleStorageChange)
}, [])
const handleSelectView = (nextView: string) => {
setMountedViews((prev) => {
@@ -20,37 +76,153 @@ function App() {
setView(nextView)
}
const handleAuthSuccess = (authData: { isAdmin: boolean; username: string | null; userId: string | null }) => {
setIsAuthenticated(true)
setIsAdmin(authData.isAdmin)
setUsername(authData.username)
}
const currentTabData = tabs.find(tab => tab.id === currentTab);
const showTerminalView = currentTabData?.type === 'terminal' || currentTabData?.type === 'server' || currentTabData?.type === 'file_manager';
const showHome = currentTabData?.type === 'home';
const showSshManager = currentTabData?.type === 'ssh_manager';
const showAdmin = currentTabData?.type === 'admin';
const showProfile = currentTabData?.type === 'profile';
return (
<div className="flex min-h-svh w-full">
<main className="flex-1 w-full">
{mountedViews.has("homepage") && (
<div style={{display: view === "homepage" ? "block" : "none"}}>
<Homepage onSelectView={handleSelectView} />
<div>
{!isAuthenticated && !authLoading && (
<div>
<div className="absolute inset-0" style={{
backgroundImage: `linear-gradient(
135deg,
transparent 0%,
transparent 49%,
rgba(255, 255, 255, 0.03) 49%,
rgba(255, 255, 255, 0.03) 51%,
transparent 51%,
transparent 100%
)`,
backgroundSize: '80px 80px'
}} />
</div>
)}
{!isAuthenticated && !authLoading && (
<div className="fixed inset-0 flex items-center justify-center z-[10000]">
<Homepage
onSelectView={handleSelectView}
isAuthenticated={isAuthenticated}
authLoading={authLoading}
onAuthSuccess={handleAuthSuccess}
isTopbarOpen={isTopbarOpen}
/>
</div>
)}
{isAuthenticated && (
<LeftSidebar
onSelectView={handleSelectView}
disabled={!isAuthenticated || authLoading}
isAdmin={isAdmin}
username={username}
>
<div
className="h-screen w-full"
style={{
visibility: showTerminalView ? "visible" : "hidden",
pointerEvents: showTerminalView ? "auto" : "none",
height: showTerminalView ? "100vh" : 0,
width: showTerminalView ? "100%" : 0,
position: showTerminalView ? "static" : "absolute",
overflow: "hidden",
}}
>
<AppView isTopbarOpen={isTopbarOpen} />
</div>
)}
{mountedViews.has("ssh_manager") && (
<div style={{display: view === "ssh_manager" ? "block" : "none"}}>
<SSHManager onSelectView={handleSelectView} />
<div
className="h-screen w-full"
style={{
visibility: showHome ? "visible" : "hidden",
pointerEvents: showHome ? "auto" : "none",
height: showHome ? "100vh" : 0,
width: showHome ? "100%" : 0,
position: showHome ? "static" : "absolute",
overflow: "hidden",
}}
>
<Homepage
onSelectView={handleSelectView}
isAuthenticated={isAuthenticated}
authLoading={authLoading}
onAuthSuccess={handleAuthSuccess}
isTopbarOpen={isTopbarOpen}
/>
</div>
)}
{mountedViews.has("terminal") && (
<div style={{display: view === "terminal" ? "block" : "none"}}>
<Terminal onSelectView={handleSelectView} />
<div
className="h-screen w-full"
style={{
visibility: showSshManager ? "visible" : "hidden",
pointerEvents: showSshManager ? "auto" : "none",
height: showSshManager ? "100vh" : 0,
width: showSshManager ? "100%" : 0,
position: showSshManager ? "static" : "absolute",
overflow: "hidden",
}}
>
<HostManager onSelectView={handleSelectView} isTopbarOpen={isTopbarOpen} />
</div>
)}
{mountedViews.has("tunnel") && (
<div style={{display: view === "tunnel" ? "block" : "none"}}>
<SSHTunnel onSelectView={handleSelectView} />
<div
className="h-screen w-full"
style={{
visibility: showAdmin ? "visible" : "hidden",
pointerEvents: showAdmin ? "auto" : "none",
height: showAdmin ? "100vh" : 0,
width: showAdmin ? "100%" : 0,
position: showAdmin ? "static" : "absolute",
overflow: "hidden",
}}
>
<AdminSettings isTopbarOpen={isTopbarOpen} />
</div>
)}
{mountedViews.has("config_editor") && (
<div style={{display: view === "config_editor" ? "block" : "none"}}>
<ConfigEditor onSelectView={handleSelectView} />
<div
className="h-screen w-full"
style={{
visibility: showProfile ? "visible" : "hidden",
pointerEvents: showProfile ? "auto" : "none",
height: showProfile ? "100vh" : 0,
width: showProfile ? "100%" : 0,
position: showProfile ? "static" : "absolute",
overflow: "auto",
}}
>
<UserProfile isTopbarOpen={isTopbarOpen} />
</div>
)}
</main>
<TopNavbar isTopbarOpen={isTopbarOpen} setIsTopbarOpen={setIsTopbarOpen}/>
</LeftSidebar>
)}
<Toaster
position="bottom-right"
richColors={false}
closeButton
duration={5000}
offset={20}
/>
</div>
)
}
function App() {
return (
<TabProvider>
<AppContent />
</TabProvider>
);
}
export default App

View File

@@ -1,109 +0,0 @@
import {HomepageSidebar} from "@/apps/Homepage/HomepageSidebar.tsx";
import React, {useEffect, useState} from "react";
import {HomepageAuth} from "@/apps/Homepage/HomepageAuth.tsx";
import axios from "axios";
import {HomepageUpdateLog} from "@/apps/Homepage/HompageUpdateLog.tsx";
import {HomepageWelcomeCard} from "@/apps/Homepage/HomepageWelcomeCard.tsx";
interface HomepageProps {
onSelectView: (view: string) => void;
}
function setCookie(name: string, value: string, days = 7) {
const expires = new Date(Date.now() + days * 864e5).toUTCString();
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
}
function getCookie(name: string) {
return document.cookie.split('; ').reduce((r, v) => {
const parts = v.split('=');
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
}, "");
}
const apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users";
const API = axios.create({
baseURL: apiBase,
});
export function Homepage({onSelectView}: HomepageProps): React.ReactElement {
const [loggedIn, setLoggedIn] = useState(false);
const [isAdmin, setIsAdmin] = useState(false);
const [username, setUsername] = useState<string | null>(null);
const [authLoading, setAuthLoading] = useState(true);
const [dbError, setDbError] = useState<string | null>(null);
const [showWelcomeCard, setShowWelcomeCard] = useState(true);
useEffect(() => {
const jwt = getCookie("jwt");
const welcomeHidden = getCookie("welcome_hidden");
if (jwt) {
setAuthLoading(true);
Promise.all([
API.get("/me", {headers: {Authorization: `Bearer ${jwt}`}}),
API.get("/db-health")
])
.then(([meRes]) => {
setLoggedIn(true);
setIsAdmin(!!meRes.data.is_admin);
setUsername(meRes.data.username || null);
setDbError(null);
setShowWelcomeCard(welcomeHidden !== "true");
})
.catch((err) => {
setLoggedIn(false);
setIsAdmin(false);
setUsername(null);
setCookie("jwt", "", -1);
if (err?.response?.data?.error?.includes("Database")) {
setDbError("Could not connect to the database. Please try again later.");
} else {
setDbError(null);
}
})
.finally(() => setAuthLoading(false));
} else {
setAuthLoading(false);
}
}, []);
const handleHideWelcomeCard = () => {
setShowWelcomeCard(false);
setCookie("welcome_hidden", "true", 365 * 10);
};
return (
<HomepageSidebar
onSelectView={onSelectView}
disabled={!loggedIn || authLoading}
isAdmin={isAdmin}
username={loggedIn ? username : null}
>
<div className="w-full min-h-svh grid place-items-center">
<div className="flex flex-row items-center justify-center gap-8">
<HomepageAuth
setLoggedIn={setLoggedIn}
setIsAdmin={setIsAdmin}
setUsername={setUsername}
loggedIn={loggedIn}
authLoading={authLoading}
dbError={dbError}
setDbError={setDbError}
/>
<HomepageUpdateLog
loggedIn={loggedIn}
/>
</div>
{loggedIn && !authLoading && showWelcomeCard && (
<div
className="absolute inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-10">
<HomepageWelcomeCard onHidePermanently={handleHideWelcomeCard}/>
</div>
)}
</div>
</HomepageSidebar>
);
}

View File

@@ -1,400 +0,0 @@
import React, {useState, useEffect} from "react";
import {cn} from "@/lib/utils";
import {Button} from "@/components/ui/button";
import {Input} from "@/components/ui/input";
import {Label} from "@/components/ui/label";
import {Alert, AlertTitle, AlertDescription} from "@/components/ui/alert";
import {Separator} from "@/components/ui/separator";
import axios from "axios";
function setCookie(name: string, value: string, days = 7) {
const expires = new Date(Date.now() + days * 864e5).toUTCString();
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
}
function getCookie(name: string) {
return document.cookie.split('; ').reduce((r, v) => {
const parts = v.split('=');
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
}, "");
}
const apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users";
const API = axios.create({
baseURL: apiBase,
});
interface HomepageAuthProps extends React.ComponentProps<"div"> {
setLoggedIn: (loggedIn: boolean) => void;
setIsAdmin: (isAdmin: boolean) => void;
setUsername: (username: string | null) => void;
loggedIn: boolean;
authLoading: boolean;
dbError: string | null;
setDbError: (error: string | null) => void;
}
export function HomepageAuth({
className,
setLoggedIn,
setIsAdmin,
setUsername,
loggedIn,
authLoading,
dbError,
setDbError,
...props
}: HomepageAuthProps) {
const [tab, setTab] = useState<"login" | "signup" | "external">("login");
const [localUsername, setLocalUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [oidcLoading, setOidcLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [internalLoggedIn, setInternalLoggedIn] = useState(false);
const [firstUser, setFirstUser] = useState(false);
const [registrationAllowed, setRegistrationAllowed] = useState(true);
const [oidcConfigured, setOidcConfigured] = useState(false);
useEffect(() => {
setInternalLoggedIn(loggedIn);
}, [loggedIn]);
useEffect(() => {
API.get("/registration-allowed").then(res => {
setRegistrationAllowed(res.data.allowed);
});
}, []);
useEffect(() => {
API.get("/oidc-config").then((response) => {
if (response.data) {
setOidcConfigured(true);
} else {
setOidcConfigured(false);
}
}).catch((error) => {
if (error.response?.status === 404) {
setOidcConfigured(false);
} else {
setOidcConfigured(false);
}
});
}, []);
useEffect(() => {
API.get("/count").then(res => {
if (res.data.count === 0) {
setFirstUser(true);
setTab("signup");
} else {
setFirstUser(false);
}
setDbError(null);
}).catch(() => {
setDbError("Could not connect to the database. Please try again later.");
});
}, [setDbError]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setLoading(true);
try {
let res, meRes;
if (tab === "login") {
res = await API.post("/login", {username: localUsername, password});
} else {
await API.post("/create", {username: localUsername, password});
res = await API.post("/login", {username: localUsername, password});
}
setCookie("jwt", res.data.token);
[meRes] = await Promise.all([
API.get("/me", {headers: {Authorization: `Bearer ${res.data.token}`}}),
API.get("/db-health")
]);
setInternalLoggedIn(true);
setLoggedIn(true);
setIsAdmin(!!meRes.data.is_admin);
setUsername(meRes.data.username || null);
setDbError(null);
} catch (err: any) {
setError(err?.response?.data?.error || "Unknown error");
setInternalLoggedIn(false);
setLoggedIn(false);
setIsAdmin(false);
setUsername(null);
setCookie("jwt", "", -1);
if (err?.response?.data?.error?.includes("Database")) {
setDbError("Could not connect to the database. Please try again later.");
} else {
setDbError(null);
}
} finally {
setLoading(false);
}
}
async function handleOIDCLogin() {
setError(null);
setOidcLoading(true);
try {
const authResponse = await API.get("/oidc/authorize");
const {auth_url: authUrl} = authResponse.data;
if (!authUrl || authUrl === 'undefined') {
throw new Error('Invalid authorization URL received from backend');
}
window.location.replace(authUrl);
} catch (err: any) {
setError(err?.response?.data?.error || err?.message || "Failed to start OIDC login");
setOidcLoading(false);
}
}
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const success = urlParams.get('success');
const token = urlParams.get('token');
const error = urlParams.get('error');
if (error) {
setError(`OIDC authentication failed: ${error}`);
setOidcLoading(false);
window.history.replaceState({}, document.title, window.location.pathname);
return;
}
if (success && token) {
setOidcLoading(true);
setError(null);
setCookie("jwt", token);
API.get("/me", {headers: {Authorization: `Bearer ${token}`}})
.then(meRes => {
setInternalLoggedIn(true);
setLoggedIn(true);
setIsAdmin(!!meRes.data.is_admin);
setUsername(meRes.data.username || null);
setDbError(null);
window.history.replaceState({}, document.title, window.location.pathname);
})
.catch(err => {
setError("Failed to get user info after OIDC login");
setInternalLoggedIn(false);
setLoggedIn(false);
setIsAdmin(false);
setUsername(null);
setCookie("jwt", "", -1);
window.history.replaceState({}, document.title, window.location.pathname);
})
.finally(() => {
setOidcLoading(false);
});
}
}, []);
const Spinner = (
<svg className="animate-spin mr-2 h-4 w-4 text-white inline-block" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
</svg>
);
return (
<div
className={cn(
className
)}
{...props}
>
<div
className={`w-[420px] max-w-full bg-background rounded-xl shadow-lg p-6 flex flex-col ${internalLoggedIn ? '' : 'border border-border'}`}>
{dbError && (
<Alert variant="destructive" className="mb-4">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{dbError}</AlertDescription>
</Alert>
)}
{firstUser && !dbError && !internalLoggedIn && (
<Alert variant="default" className="mb-4">
<AlertTitle>First User</AlertTitle>
<AlertDescription className="inline">
You are the first user and will be made an admin. You can view admin settings in the sidebar
user dropdown. If you think this is a mistake, check the docker logs, or create a{" "}
<a
href="https://github.com/LukeGus/Termix/issues/new"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 underline hover:text-blue-800 inline"
>
GitHub issue
</a>.
</AlertDescription>
</Alert>
)}
{!registrationAllowed && !internalLoggedIn && (
<Alert variant="destructive" className="mb-4">
<AlertTitle>Registration Disabled</AlertTitle>
<AlertDescription>
New account registration is currently disabled by an admin. Please log in or contact an
administrator.
</AlertDescription>
</Alert>
)}
{(internalLoggedIn || (authLoading && getCookie("jwt"))) && (
<div className="flex flex-col items-center gap-4">
<Alert className="my-2">
<AlertTitle>Logged in!</AlertTitle>
<AlertDescription>
You are logged in! Use the sidebar to access all available tools. To get started,
create an SSH Host in the SSH Manager tab. Once created, you can connect to that
host using the other apps in the sidebar.
</AlertDescription>
</Alert>
<div className="flex flex-row items-center gap-2">
<Button
variant="link"
className="text-sm"
onClick={() => window.open('https://github.com/LukeGus/Termix', '_blank')}
>
GitHub
</Button>
<div className="w-px h-4 bg-border"></div>
<Button
variant="link"
className="text-sm"
onClick={() => window.open('https://github.com/LukeGus/Termix/issues/new', '_blank')}
>
Feedback
</Button>
<div className="w-px h-4 bg-border"></div>
<Button
variant="link"
className="text-sm"
onClick={() => window.open('https://discord.com/invite/jVQGdvHDrf', '_blank')}
>
Discord
</Button>
<div className="w-px h-4 bg-border"></div>
<Button
variant="link"
className="text-sm"
onClick={() => window.open('https://github.com/sponsors/LukeGus', '_blank')}
>
Fund
</Button>
</div>
</div>
)}
{(!internalLoggedIn && (!authLoading || !getCookie("jwt"))) && (
<>
<div className="flex gap-2 mb-6">
<button
type="button"
className={cn(
"flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "login"
? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent"
)}
onClick={() => setTab("login")}
aria-selected={tab === "login"}
disabled={loading || firstUser}
>
Login
</button>
<button
type="button"
className={cn(
"flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "signup"
? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent"
)}
onClick={() => setTab("signup")}
aria-selected={tab === "signup"}
disabled={loading || !registrationAllowed}
>
Sign Up
</button>
{oidcConfigured && (
<button
type="button"
className={cn(
"flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "external"
? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent"
)}
onClick={() => setTab("external")}
aria-selected={tab === "external"}
disabled={oidcLoading}
>
External
</button>
)}
</div>
<div className="mb-6 text-center">
<h2 className="text-xl font-bold mb-1">
{tab === "login" ? "Login to your account" :
tab === "signup" ? "Create a new account" :
"Login with external provider"}
</h2>
</div>
{tab === "external" ? (
<div className="flex flex-col gap-5">
<div className="text-center text-muted-foreground mb-4">
<p>Login using your configured external identity provider</p>
</div>
<Button
type="button"
className="w-full h-11 mt-2 text-base font-semibold"
disabled={oidcLoading}
onClick={handleOIDCLogin}
>
{oidcLoading ? Spinner : "Login with External Provider"}
</Button>
</div>
) : (
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
type="text"
required
className="h-11 text-base"
value={localUsername}
onChange={e => setLocalUsername(e.target.value)}
disabled={loading || internalLoggedIn}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="password">Password</Label>
<Input id="password" type="password" required className="h-11 text-base"
value={password} onChange={e => setPassword(e.target.value)}
disabled={loading || internalLoggedIn}/>
</div>
<Button type="submit" className="w-full h-11 mt-2 text-base font-semibold"
disabled={loading || internalLoggedIn}>
{loading ? Spinner : (tab === "login" ? "Login" : "Sign Up")}
</Button>
</form>
)}
</>
)}
{error && (
<Alert variant="destructive" className="mt-4">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</div>
</div>
);
}

View File

@@ -1,438 +0,0 @@
import React from 'react';
import {
Computer,
Server,
File,
Hammer, ChevronUp, User2, HardDrive
} from "lucide-react";
import {
Sidebar,
SidebarContent, SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem, SidebarProvider, SidebarInset,
} from "@/components/ui/sidebar.tsx"
import {
Separator,
} from "@/components/ui/separator.tsx"
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@radix-ui/react-dropdown-menu";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
SheetClose
} from "@/components/ui/sheet";
import {Checkbox} from "@/components/ui/checkbox.tsx";
import {Input} from "@/components/ui/input.tsx";
import {Label} from "@/components/ui/label.tsx";
import {Button} from "@/components/ui/button.tsx";
import {Alert, AlertTitle, AlertDescription} from "@/components/ui/alert.tsx";
import axios from "axios";
interface SidebarProps {
onSelectView: (view: string) => void;
getView?: () => string;
disabled?: boolean;
isAdmin?: boolean;
username?: string | null;
children?: React.ReactNode;
}
function handleLogout() {
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
window.location.reload();
}
function getCookie(name: string) {
return document.cookie.split('; ').reduce((r, v) => {
const parts = v.split('=');
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
}, "");
}
const apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users";
const API = axios.create({
baseURL: apiBase,
});
export function HomepageSidebar({
onSelectView,
getView,
disabled,
isAdmin,
username,
children,
}: SidebarProps): React.ReactElement {
const [adminSheetOpen, setAdminSheetOpen] = React.useState(false);
const [allowRegistration, setAllowRegistration] = React.useState(true);
const [regLoading, setRegLoading] = React.useState(false);
const [oidcConfig, setOidcConfig] = React.useState({
client_id: '',
client_secret: '',
issuer_url: '',
authorization_url: '',
token_url: '',
identifier_path: 'sub',
name_path: 'name',
scopes: 'openid email profile'
});
const [oidcLoading, setOidcLoading] = React.useState(false);
const [oidcError, setOidcError] = React.useState<string | null>(null);
const [oidcSuccess, setOidcSuccess] = React.useState<string | null>(null);
React.useEffect(() => {
if (adminSheetOpen) {
API.get("/registration-allowed").then(res => {
setAllowRegistration(res.data.allowed);
});
API.get("/oidc-config").then(res => {
if (res.data) {
setOidcConfig(res.data);
}
}).catch((error) => {
});
}
}, [adminSheetOpen]);
const handleToggle = async (checked: boolean) => {
setRegLoading(true);
const jwt = getCookie("jwt");
try {
await API.patch(
"/registration-allowed",
{allowed: checked},
{headers: {Authorization: `Bearer ${jwt}`}}
);
setAllowRegistration(checked);
} catch (e) {
} finally {
setRegLoading(false);
}
};
const handleOIDCConfigSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setOidcLoading(true);
setOidcError(null);
setOidcSuccess(null);
const requiredFields = ['client_id', 'client_secret', 'issuer_url', 'authorization_url', 'token_url'];
const missingFields = requiredFields.filter(field => !oidcConfig[field as keyof typeof oidcConfig]);
if (missingFields.length > 0) {
setOidcError(`Missing required fields: ${missingFields.join(', ')}`);
setOidcLoading(false);
return;
}
const jwt = getCookie("jwt");
try {
await API.post(
"/oidc-config",
oidcConfig,
{headers: {Authorization: `Bearer ${jwt}`}}
);
setOidcSuccess("OIDC configuration updated successfully!");
} catch (err: any) {
setOidcError(err?.response?.data?.error || "Failed to update OIDC configuration");
} finally {
setOidcLoading(false);
}
};
const handleOIDCConfigChange = (field: string, value: string) => {
setOidcConfig(prev => ({
...prev,
[field]: value
}));
};
return (
<div className="min-h-svh">
<SidebarProvider>
<Sidebar>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
Termix
</SidebarGroupLabel>
<Separator className="p-0.25 mt-1 mb-1"/>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem key={"SSH Manager"}>
<SidebarMenuButton onClick={() => onSelectView("ssh_manager")}
disabled={disabled}>
<HardDrive/>
<span>SSH Manager</span>
</SidebarMenuButton>
</SidebarMenuItem>
<div className="ml-5">
<SidebarMenuItem key={"Terminal"}>
<SidebarMenuButton onClick={() => onSelectView("terminal")}
disabled={disabled}>
<Computer/>
<span>Terminal</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem key={"Tunnel"}>
<SidebarMenuButton onClick={() => onSelectView("tunnel")}
disabled={disabled}>
<Server/>
<span>Tunnel</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem key={"Config Editor"}>
<SidebarMenuButton onClick={() => onSelectView("config_editor")}
disabled={disabled}>
<File/>
<span>Config Editor</span>
</SidebarMenuButton>
</SidebarMenuItem>
</div>
<SidebarMenuItem key={"Tools"}>
<SidebarMenuButton onClick={() => window.open("https://dashix.dev", "_blank")} disabled={disabled}>
<Hammer/>
<span>Tools</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<Separator className="p-0.25 mt-1 mb-1"/>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
className="data-[state=open]:opacity-90 w-full"
style={{width: '100%'}}
disabled={disabled}
>
<User2/> {username ? username : 'Signed out'}
<ChevronUp className="ml-auto"/>
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="start"
sideOffset={6}
className="min-w-[var(--radix-popper-anchor-width)] bg-sidebar-accent text-sidebar-accent-foreground border border-border rounded-md shadow-2xl p-1"
>
{isAdmin && (
<DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onSelect={() => setAdminSheetOpen(true)}>
<span>Admin Settings</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onSelect={handleLogout}>
<span>Sign out</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
{/* Admin Settings Sheet (always rendered, only openable if isAdmin) */}
{isAdmin && (
<Sheet open={adminSheetOpen} onOpenChange={setAdminSheetOpen}>
<SheetContent side="left" className="w-[400px] max-h-screen overflow-y-auto">
<SheetHeader>
<SheetTitle>Admin Settings</SheetTitle>
</SheetHeader>
<div className="pt-1 pb-4 px-4 flex flex-col gap-6">
{/* Registration Settings */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">User Registration</h3>
<label className="flex items-center gap-2">
<Checkbox checked={allowRegistration} onCheckedChange={handleToggle}
disabled={regLoading}/>
Allow new account registration
</label>
</div>
<Separator className="p-0.25 mt-2 mb-2"/>
{/* OIDC Configuration */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">External Authentication (OIDC)</h3>
<p className="text-sm text-muted-foreground">
Configure external identity provider for OIDC/OAuth2 authentication.
Users will see an "External" login option once configured.
</p>
{oidcError && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{oidcError}</AlertDescription>
</Alert>
)}
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="client_id">Client ID</Label>
<Input
id="client_id"
value={oidcConfig.client_id}
onChange={(e) => handleOIDCConfigChange('client_id', e.target.value)}
placeholder="your-client-id"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="client_secret">Client Secret</Label>
<Input
id="client_secret"
type="password"
value={oidcConfig.client_secret}
onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)}
placeholder="your-client-secret"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="authorization_url">Authorization URL</Label>
<Input
id="authorization_url"
value={oidcConfig.authorization_url}
onChange={(e) => handleOIDCConfigChange('authorization_url', e.target.value)}
placeholder="https://your-provider.com/application/o/authorize/"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="issuer_url">Issuer URL</Label>
<Input
id="issuer_url"
value={oidcConfig.issuer_url}
onChange={(e) => handleOIDCConfigChange('issuer_url', e.target.value)}
placeholder="https://your-provider.com/application/o/termix/"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="token_url">Token URL</Label>
<Input
id="token_url"
value={oidcConfig.token_url}
onChange={(e) => handleOIDCConfigChange('token_url', e.target.value)}
placeholder="http://100.98.3.50:9000/application/o/token/"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="identifier_path">User Identifier Path</Label>
<Input
id="identifier_path"
value={oidcConfig.identifier_path}
onChange={(e) => handleOIDCConfigChange('identifier_path', e.target.value)}
placeholder="sub"
required
/>
<p className="text-xs text-muted-foreground">
JSON path to extract user ID from JWT (e.g., "sub", "email", "preferred_username")
</p>
</div>
<div className="space-y-2">
<Label htmlFor="name_path">Display Name Path</Label>
<Input
id="name_path"
value={oidcConfig.name_path}
onChange={(e) => handleOIDCConfigChange('name_path', e.target.value)}
placeholder="name"
required
/>
<p className="text-xs text-muted-foreground">
JSON path to extract display name from JWT (e.g., "name", "preferred_username")
</p>
</div>
<div className="space-y-2">
<Label htmlFor="scopes">Scopes</Label>
<Input
id="scopes"
value={oidcConfig.scopes}
onChange={(e) => handleOIDCConfigChange('scopes', e.target.value)}
placeholder="openid email profile"
required
/>
<p className="text-xs text-muted-foreground">
Space-separated list of OAuth2 scopes to request
</p>
</div>
<div className="flex gap-2">
<Button
type="submit"
className="flex-1"
disabled={oidcLoading}
>
{oidcLoading ? "Saving..." : "Save Configuration"}
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
setOidcConfig({
client_id: '',
client_secret: '',
issuer_url: '',
authorization_url: '',
token_url: '',
identifier_path: 'sub',
name_path: 'name',
scopes: 'openid email profile'
});
}}
>
Reset
</Button>
</div>
{oidcSuccess && (
<Alert>
<AlertTitle>Success</AlertTitle>
<AlertDescription>{oidcSuccess}</AlertDescription>
</Alert>
)}
</form>
</div>
</div>
<SheetFooter className="px-4 pt-1 pb-4">
<Separator className="p-0.25 mt-2 mb-2"/>
<SheetClose asChild>
<Button variant="outline">Close</Button>
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
)}
</Sidebar>
<SidebarInset>
{children}
</SidebarInset>
</SidebarProvider>
</div>
)
}

View File

@@ -1,58 +0,0 @@
import React from "react";
import {Card, CardContent, CardFooter, CardHeader, CardTitle} from "@/components/ui/card";
import {Button} from "@/components/ui/button";
interface HomepageWelcomeCardProps {
onHidePermanently: () => void;
}
export function HomepageWelcomeCard({onHidePermanently}: HomepageWelcomeCardProps): React.ReactElement {
return (
<Card className="w-full max-w-2xl mx-auto">
<CardHeader>
<CardTitle className="text-2xl font-bold text-center">
The Future of Termix
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-center leading-relaxed">
Please checkout the linked survey{" "}
<a
href="https://docs.google.com/forms/d/e/1FAIpQLSeGvnQODFtnpjmJsMKgASbaQ87CLQEBCcnzK_Vuw5TdfbfIyA/viewform?usp=sharing&ouid=107601685503825301492"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline hover:text-primary/80 transition-colors"
>
here
</a>
. The purpose of this survey is to gather feedback from users on what the future UI of Termix could
look like to optimize server management. Please take a minute or two to read the survey questions
and answer them to the best of your ability. Thank you!
</p>
<p className="text-muted-foreground text-center leading-relaxed mt-6">
A special thanks to those in Asia who recently joined Termix through various forum posts, keep
sharing it! A Chinese translation is planned for Termix, but since I dont speak Chinese, Ill need
to hire someone to help with the translation. If youd like to support me financially, you can do
so{" "}
<a
href="https://github.com/sponsors/LukeGus"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline hover:text-primary/80 transition-colors"
>
here.
</a>
</p>
</CardContent>
<CardFooter className="justify-center">
<Button
variant="outline"
onClick={onHidePermanently}
className="w-full max-w-xs"
>
Hide Permanently
</Button>
</CardFooter>
</Card>
);
}

View File

@@ -1,594 +0,0 @@
import React, {useEffect, useState, useRef, forwardRef, useImperativeHandle} from 'react';
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel, SidebarMenu, SidebarMenuItem,
SidebarProvider
} from '@/components/ui/sidebar.tsx';
import {Separator} from '@/components/ui/separator.tsx';
import {CornerDownLeft, Folder, File, Server, ArrowUp, Pin} from 'lucide-react';
import {ScrollArea} from '@/components/ui/scroll-area.tsx';
import {cn} from '@/lib/utils.ts';
import {Input} from '@/components/ui/input.tsx';
import {Button} from '@/components/ui/button.tsx';
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from '@/components/ui/accordion.tsx';
import {
getSSHHosts,
listSSHFiles,
connectSSH,
getSSHStatus,
getConfigEditorPinned,
addConfigEditorPinned,
removeConfigEditorPinned
} from '@/apps/SSH/ssh-axios.ts';
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableConfigEditor: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
updatedAt: string;
}
const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
{onSelectView, onOpenFile, tabs, onHostChange}: {
onSelectView: (view: string) => void;
onOpenFile: (file: any) => void;
tabs: any[];
onHostChange?: (host: SSHHost | null) => void;
},
ref
) {
const [sshConnections, setSSHConnections] = useState<SSHHost[]>([]);
const [loadingSSH, setLoadingSSH] = useState(false);
const [errorSSH, setErrorSSH] = useState<string | undefined>(undefined);
const [view, setView] = useState<'servers' | 'files'>('servers');
const [activeServer, setActiveServer] = useState<SSHHost | null>(null);
const [currentPath, setCurrentPath] = useState('/');
const [files, setFiles] = useState<any[]>([]);
const pathInputRef = useRef<HTMLInputElement>(null);
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [fileSearch, setFileSearch] = useState('');
const [debouncedFileSearch, setDebouncedFileSearch] = useState('');
useEffect(() => {
const handler = setTimeout(() => setDebouncedSearch(search), 200);
return () => clearTimeout(handler);
}, [search]);
useEffect(() => {
const handler = setTimeout(() => setDebouncedFileSearch(fileSearch), 200);
return () => clearTimeout(handler);
}, [fileSearch]);
const [sshSessionId, setSshSessionId] = useState<string | null>(null);
const [filesLoading, setFilesLoading] = useState(false);
const [filesError, setFilesError] = useState<string | null>(null);
const [connectingSSH, setConnectingSSH] = useState(false);
const [connectionCache, setConnectionCache] = useState<Record<string, {
sessionId: string;
timestamp: number
}>>({});
const [fetchingFiles, setFetchingFiles] = useState(false);
useEffect(() => {
fetchSSH();
}, []);
async function fetchSSH() {
setLoadingSSH(true);
setErrorSSH(undefined);
try {
const hosts = await getSSHHosts();
const configEditorHosts = hosts.filter(host => host.enableConfigEditor);
if (configEditorHosts.length > 0) {
const firstHost = configEditorHosts[0];
}
setSSHConnections(configEditorHosts);
} catch (err: any) {
setErrorSSH('Failed to load SSH connections');
} finally {
setLoadingSSH(false);
}
}
async function connectToSSH(server: SSHHost): Promise<string | null> {
const sessionId = server.id.toString();
const cached = connectionCache[sessionId];
if (cached && Date.now() - cached.timestamp < 30000) {
setSshSessionId(cached.sessionId);
return cached.sessionId;
}
if (connectingSSH) {
return null;
}
setConnectingSSH(true);
try {
if (!server.password && !server.key) {
setFilesError('No authentication credentials available for this SSH host');
return null;
}
const connectionConfig = {
ip: server.ip,
port: server.port,
username: server.username,
password: server.password,
sshKey: server.key,
keyPassword: server.keyPassword,
};
await connectSSH(sessionId, connectionConfig);
setSshSessionId(sessionId);
setConnectionCache(prev => ({
...prev,
[sessionId]: {sessionId, timestamp: Date.now()}
}));
return sessionId;
} catch (err: any) {
setFilesError(err?.response?.data?.error || 'Failed to connect to SSH');
setSshSessionId(null);
return null;
} finally {
setConnectingSSH(false);
}
}
async function fetchFiles() {
if (fetchingFiles) {
return;
}
setFetchingFiles(true);
setFiles([]);
setFilesLoading(true);
setFilesError(null);
try {
let pinnedFiles: any[] = [];
try {
if (activeServer) {
pinnedFiles = await getConfigEditorPinned(activeServer.id);
}
} catch (err) {
}
if (activeServer && sshSessionId) {
let res: any[] = [];
try {
const status = await getSSHStatus(sshSessionId);
if (!status.connected) {
const newSessionId = await connectToSSH(activeServer);
if (newSessionId) {
setSshSessionId(newSessionId);
res = await listSSHFiles(newSessionId, currentPath);
} else {
throw new Error('Failed to reconnect SSH session');
}
} else {
res = await listSSHFiles(sshSessionId, currentPath);
}
} catch (sessionErr) {
const newSessionId = await connectToSSH(activeServer);
if (newSessionId) {
setSshSessionId(newSessionId);
res = await listSSHFiles(newSessionId, currentPath);
} else {
throw sessionErr;
}
}
const processedFiles = (res || []).map((f: any) => {
const filePath = currentPath + (currentPath.endsWith('/') ? '' : '/') + f.name;
const isPinned = pinnedFiles.some(pinned => pinned.path === filePath);
return {
...f,
path: filePath,
isPinned,
isSSH: true,
sshSessionId: sshSessionId
};
});
setFiles(processedFiles);
}
} catch (err: any) {
setFiles([]);
setFilesError(err?.response?.data?.error || err?.message || 'Failed to list files');
} finally {
setFilesLoading(false);
setFetchingFiles(false);
}
}
useEffect(() => {
if (view === 'files' && activeServer && sshSessionId && !connectingSSH && !fetchingFiles) {
const timeoutId = setTimeout(() => {
fetchFiles();
}, 100);
return () => clearTimeout(timeoutId);
}
}, [currentPath, view, activeServer, sshSessionId]);
async function handleSelectServer(server: SSHHost) {
if (connectingSSH) {
return;
}
setFetchingFiles(false);
setFilesLoading(false);
setFilesError(null);
setFiles([]);
setActiveServer(server);
setCurrentPath(server.defaultPath || '/');
setView('files');
const sessionId = await connectToSSH(server);
if (sessionId) {
setSshSessionId(sessionId);
if (onHostChange) {
onHostChange(server);
}
} else {
w
setView('servers');
setActiveServer(null);
}
}
useImperativeHandle(ref, () => ({
openFolder: async (server: SSHHost, path: string) => {
if (connectingSSH || fetchingFiles) {
return;
}
if (activeServer?.id === server.id && currentPath === path) {
setTimeout(() => fetchFiles(), 100);
return;
}
setFetchingFiles(false);
setFilesLoading(false);
setFilesError(null);
setFiles([]);
setActiveServer(server);
setCurrentPath(path);
setView('files');
if (!sshSessionId || activeServer?.id !== server.id) {
const sessionId = await connectToSSH(server);
if (sessionId) {
setSshSessionId(sessionId);
if (onHostChange && activeServer?.id !== server.id) {
onHostChange(server);
}
} else {
setView('servers');
setActiveServer(null);
}
} else {
if (onHostChange && activeServer?.id !== server.id) {
onHostChange(server);
}
}
},
fetchFiles: () => {
if (activeServer && sshSessionId) {
fetchFiles();
}
}
}));
useEffect(() => {
if (pathInputRef.current) {
pathInputRef.current.scrollLeft = pathInputRef.current.scrollWidth;
}
}, [currentPath]);
const sshByFolder: Record<string, SSHHost[]> = {};
sshConnections.forEach(conn => {
const folder = conn.folder && conn.folder.trim() ? conn.folder : 'No Folder';
if (!sshByFolder[folder]) sshByFolder[folder] = [];
sshByFolder[folder].push(conn);
});
const sortedFolders = Object.keys(sshByFolder);
if (sortedFolders.includes('No Folder')) {
sortedFolders.splice(sortedFolders.indexOf('No Folder'), 1);
sortedFolders.unshift('No Folder');
}
const filteredSshByFolder: Record<string, SSHHost[]> = {};
Object.entries(sshByFolder).forEach(([folder, hosts]) => {
filteredSshByFolder[folder] = hosts.filter(conn => {
const q = debouncedSearch.trim().toLowerCase();
if (!q) return true;
return (conn.name || '').toLowerCase().includes(q) || (conn.ip || '').toLowerCase().includes(q) ||
(conn.username || '').toLowerCase().includes(q) || (conn.folder || '').toLowerCase().includes(q) ||
(conn.tags || []).join(' ').toLowerCase().includes(q);
});
});
const filteredFiles = files.filter(file => {
const q = debouncedFileSearch.trim().toLowerCase();
if (!q) return true;
return file.name.toLowerCase().includes(q);
});
return (
<SidebarProvider>
<Sidebar style={{height: '100vh', maxHeight: '100vh', overflow: 'hidden'}}>
<SidebarContent style={{height: '100vh', maxHeight: '100vh', overflow: 'hidden'}}>
<SidebarGroup className="flex flex-col flex-grow h-full overflow-hidden">
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
Termix / Config
</SidebarGroupLabel>
<Separator className="p-0.25 mt-1 mb-1"/>
<SidebarGroupContent className="flex flex-col flex-grow min-h-0">
<SidebarMenu>
<SidebarMenuItem key={"Homepage"}>
<Button className="w-full mt-2 mb-2 h-8" onClick={() => onSelectView("homepage")}
variant="outline">
<CornerDownLeft/>
Return
</Button>
<Separator className="p-0.25 mt-1 mb-1"/>
</SidebarMenuItem>
</SidebarMenu>
<div
className="flex-1 w-full flex flex-col rounded-md bg-[#09090b] border border-[#434345] overflow-hidden p-0 relative min-h-0 mt-1">
{view === 'servers' && (
<>
<div
className="w-full px-2 pt-2 pb-2 bg-[#09090b] z-10 border-b border-[#23232a]">
<Input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search hosts by name, username, IP, folder, tags..."
className="w-full h-8 text-sm bg-[#18181b] border border-[#23232a] text-white placeholder:text-muted-foreground rounded"
autoComplete="off"
/>
</div>
<ScrollArea className="flex-1 w-full h-full"
style={{height: '100%', maxHeight: '100%'}}>
<div className="flex flex-col h-full">
<div
className="w-full flex-grow overflow-hidden p-0 m-0 relative flex flex-col min-h-0">
<div style={{display: 'flex', justifyContent: 'center'}}>
<Separator className="w-full h-px bg-[#434345] my-2"
style={{maxWidth: 213, margin: '0 auto'}}/>
</div>
<div className="mx-auto" style={{maxWidth: '213px', width: '100%'}}>
<div className="flex-1 min-h-0">
<Accordion type="multiple" className="w-full"
value={sortedFolders}>
{sortedFolders.map((folder, idx) => (
<React.Fragment key={folder}>
<AccordionItem value={folder}
className="mt-0 w-full !border-b-transparent">
<AccordionTrigger
className="text-base font-semibold rounded-t-none py-2 w-full">{folder}</AccordionTrigger>
<AccordionContent
className="flex flex-col gap-1 pb-2 pt-1 w-full">
{filteredSshByFolder[folder].map(conn => (
<Button
key={conn.id}
variant="outline"
className="w-full h-10 px-2 bg-[#18181b] border border-[#434345] hover:bg-[#2d2d30] transition-colors text-left justify-start"
onClick={() => handleSelectServer(conn)}
>
<div
className="flex items-center w-full">
{conn.pin && <Pin
className="w-0.5 h-0.5 text-yellow-400 mr-1 flex-shrink-0"/>}
<span
className="font-medium truncate">{conn.name || conn.ip}</span>
</div>
</Button>
))}
</AccordionContent>
</AccordionItem>
{idx < sortedFolders.length - 1 && (
<div style={{
display: 'flex',
justifyContent: 'center'
}}>
<Separator
className="h-px bg-[#434345] my-1"
style={{width: 213}}/>
</div>
)}
</React.Fragment>
))}
</Accordion>
</div>
</div>
</div>
</div>
</ScrollArea>
</>
)}
{view === 'files' && activeServer && (
<div className="flex flex-col h-full w-full" style={{maxWidth: 260}}>
<div
className="flex items-center gap-2 px-2 py-2 border-b border-[#23232a] bg-[#18181b] z-20"
style={{maxWidth: 260}}>
<Button
size="icon"
variant="outline"
className="h-8 w-8 bg-[#18181b] border border-[#23232a] rounded-md hover:bg-[#2d2d30] focus:outline-none focus:ring-2 focus:ring-ring"
onClick={() => {
let path = currentPath;
if (path && path !== '/' && path !== '') {
if (path.endsWith('/')) path = path.slice(0, -1);
const lastSlash = path.lastIndexOf('/');
if (lastSlash > 0) {
setCurrentPath(path.slice(0, lastSlash));
} else {
setCurrentPath('/');
}
} else {
setView('servers');
if (onHostChange) {
onHostChange(null);
}
}
}}
>
<ArrowUp className="w-4 h-4"/>
</Button>
<Input ref={pathInputRef} value={currentPath}
onChange={e => setCurrentPath(e.target.value)}
className="flex-1 bg-[#18181b] border border-[#434345] text-white truncate rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring hover:border-[#5a5a5d]"
/>
</div>
<div className="px-2 py-2 border-b border-[#23232a] bg-[#18181b]">
<Input
placeholder="Search files and folders..."
className="w-full h-7 text-sm bg-[#23232a] border border-[#434345] text-white placeholder:text-muted-foreground rounded"
autoComplete="off"
value={fileSearch}
onChange={e => setFileSearch(e.target.value)}
/>
</div>
<div className="flex-1 w-full h-full bg-[#09090b] border-t border-[#23232a]">
<ScrollArea className="w-full h-full bg-[#09090b]" style={{
height: '100%',
maxHeight: '100%',
paddingRight: 8,
scrollbarGutter: 'stable',
background: '#09090b'
}}>
<div className="p-2 pr-2">
{connectingSSH || filesLoading ? (
<div className="text-xs text-muted-foreground">Loading...</div>
) : filesError ? (
<div className="text-xs text-red-500">{filesError}</div>
) : filteredFiles.length === 0 ? (
<div className="text-xs text-muted-foreground">No files or
folders found.</div>
) : (
<div className="flex flex-col gap-1">
{filteredFiles.map((item: any) => {
const isOpen = (tabs || []).some((t: any) => t.id === item.path);
return (
<div
key={item.path}
className={cn(
"flex items-center gap-2 px-3 py-2 bg-[#18181b] border border-[#23232a] rounded group max-w-full",
isOpen && "opacity-60 cursor-not-allowed pointer-events-none"
)}
style={{maxWidth: 220, marginBottom: 8}}
>
<div
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
onClick={() => !isOpen && (item.type === 'directory' ? setCurrentPath(item.path) : onOpenFile({
name: item.name,
path: item.path,
isSSH: item.isSSH,
sshSessionId: item.sshSessionId
}))}
>
{item.type === 'directory' ?
<Folder
className="w-4 h-4 text-blue-400"/> :
<File
className="w-4 h-4 text-muted-foreground"/>}
<span
className="text-sm text-white truncate max-w-[120px]">{item.name}</span>
</div>
<div className="flex items-center gap-1">
{item.type === 'file' && (
<Button size="icon" variant="ghost"
className="h-7 w-7"
disabled={isOpen}
onClick={async (e) => {
e.stopPropagation();
try {
if (item.isPinned) {
await removeConfigEditorPinned({
name: item.name,
path: item.path,
hostId: activeServer?.id,
isSSH: true,
sshSessionId: activeServer?.id.toString()
});
setFiles(files.map(f =>
f.path === item.path ? {
...f,
isPinned: false
} : f
));
} else {
await addConfigEditorPinned({
name: item.name,
path: item.path,
hostId: activeServer?.id,
isSSH: true,
sshSessionId: activeServer?.id.toString()
});
setFiles(files.map(f =>
f.path === item.path ? {
...f,
isPinned: true
} : f
));
}
} catch (err) {
console.error('Failed to pin/unpin file:', err);
}
}}
>
<Pin
className={`w-1 h-1 ${item.isPinned ? 'text-yellow-400 fill-current' : 'text-muted-foreground'}`}/>
</Button>
)}
</div>
</div>
);
})}
</div>
)}
</div>
</ScrollArea>
</div>
</div>
)}
</div>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
</SidebarProvider>
);
});
export {ConfigEditorSidebar};

View File

@@ -1,57 +0,0 @@
import React from 'react';
import {Button} from '@/components/ui/button.tsx';
import {X, Home} from 'lucide-react';
interface ConfigTab {
id: string | number;
title: string;
}
interface ConfigTabListProps {
tabs: ConfigTab[];
activeTab: string | number;
setActiveTab: (tab: string | number) => void;
closeTab: (tab: string | number) => void;
onHomeClick: () => void;
}
export function ConfigTabList({tabs, activeTab, setActiveTab, closeTab, onHomeClick}: ConfigTabListProps) {
return (
<div className="inline-flex items-center h-full px-[0.5rem] overflow-x-auto">
<Button
onClick={onHomeClick}
variant="outline"
className={`h-7 mr-[0.5rem] rounded-md flex items-center ${activeTab === 'home' ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
>
<Home className="w-4 h-4"/>
</Button>
{tabs.map((tab, index) => {
const isActive = tab.id === activeTab;
return (
<div
key={tab.id}
className={index < tabs.length - 1 ? "mr-[0.5rem]" : ""}
>
<div className="inline-flex rounded-md shadow-sm" role="group">
<Button
onClick={() => setActiveTab(tab.id)}
variant="outline"
className={`h-7 rounded-r-none ${isActive ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
>
{tab.title}
</Button>
<Button
onClick={() => closeTab(tab.id)}
variant="outline"
className="h-7 rounded-l-none p-0 !w-9"
>
<X className="!w-5 !h-5" strokeWidth={2.5}/>
</Button>
</div>
</div>
);
})}
</div>
);
}

View File

@@ -1,8 +0,0 @@
import React from "react";
import { ConfigTabList } from "./ConfigTabList.tsx";
export function ConfigTopbar(props: any): React.ReactElement {
return (
<ConfigTabList {...props} />
)
}

View File

@@ -1,59 +0,0 @@
import React from 'react';
import {
CornerDownLeft
} from "lucide-react"
import {
Button
} from "@/components/ui/button.tsx"
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuItem, SidebarProvider,
} from "@/components/ui/sidebar.tsx"
import {
Separator,
} from "@/components/ui/separator.tsx"
interface SidebarProps {
onSelectView: (view: string) => void;
}
export function SSHManagerSidebar({onSelectView}: SidebarProps): React.ReactElement {
return (
<SidebarProvider>
<Sidebar>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
Termix / SSH Manager
</SidebarGroupLabel>
<Separator className="p-0.25 mt-1 mb-1"/>
<SidebarGroupContent className="flex flex-col flex-grow">
<SidebarMenu>
{/* Sidebar Items */}
<SidebarMenuItem key={"Homepage"}>
<Button className="w-full mt-2 mb-2 h-8" onClick={() => onSelectView("homepage")}
variant="outline">
<CornerDownLeft/>
Return
</Button>
<Separator className="p-0.25 mt-1 mb-1"/>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
</SidebarProvider>
)
}

View File

@@ -1,784 +0,0 @@
import React, {useState, useRef, useEffect} from "react";
import {TerminalSidebar} from "@/apps/SSH/Terminal/TerminalSidebar.tsx";
import {TerminalComponent} from "./TerminalComponent.tsx";
import {TerminalTopbar} from "@/apps/SSH/Terminal/TerminalTopbar.tsx";
import {ResizablePanelGroup, ResizablePanel, ResizableHandle} from '@/components/ui/resizable.tsx';
import * as ResizablePrimitive from "react-resizable-panels";
import {ChevronDown, ChevronRight} from "lucide-react";
interface ConfigEditorProps {
onSelectView: (view: string) => void;
}
type Tab = {
id: number;
title: string;
hostConfig: any;
terminalRef: React.RefObject<any>;
};
export function Terminal({onSelectView}: ConfigEditorProps): React.ReactElement {
const [allTabs, setAllTabs] = useState<Tab[]>([]);
const [currentTab, setCurrentTab] = useState<number | null>(null);
const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]);
const nextTabId = useRef(1);
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(true);
const [isTopbarOpen, setIsTopbarOpen] = useState<boolean>(true);
const SIDEBAR_WIDTH = 256;
const HANDLE_THICKNESS = 10;
const [panelRects, setPanelRects] = useState<Record<string, DOMRect | null>>({});
const panelRefs = useRef<Record<string, HTMLDivElement | null>>({});
const panelGroupRefs = useRef<{ [key: string]: any }>({});
const setActiveTab = (tabId: number) => {
setCurrentTab(tabId);
};
const fitVisibleTerminals = () => {
allTabs.forEach((terminal) => {
const isVisible =
(allSplitScreenTab.length === 0 && terminal.id === currentTab) ||
(allSplitScreenTab.length > 0 && (terminal.id === currentTab || allSplitScreenTab.includes(terminal.id)));
if (isVisible && terminal.terminalRef && terminal.terminalRef.current && typeof terminal.terminalRef.current.fit === 'function') {
terminal.terminalRef.current.fit();
}
});
};
const setSplitScreenTab = (tabId: number) => {
fitVisibleTerminals();
setAllSplitScreenTab((prev) => {
let next;
if (prev.includes(tabId)) {
next = prev.filter((id) => id !== tabId);
} else if (prev.length < 3) {
next = [...prev, tabId];
} else {
next = prev;
}
setTimeout(() => fitVisibleTerminals(), 0);
return next;
});
};
const setCloseTab = (tabId: number) => {
const tab = allTabs.find((t) => t.id === tabId);
if (tab && tab.terminalRef && tab.terminalRef.current && typeof tab.terminalRef.current.disconnect === "function") {
tab.terminalRef.current.disconnect();
}
setAllTabs((prev) => prev.filter((tab) => tab.id !== tabId));
setAllSplitScreenTab((prev) => prev.filter((id) => id !== tabId));
if (currentTab === tabId) {
const remainingTabs = allTabs.filter((tab) => tab.id !== tabId);
setCurrentTab(remainingTabs.length > 0 ? remainingTabs[0].id : null);
}
};
const updatePanelRects = () => {
setPanelRects((prev) => {
const next: Record<string, DOMRect | null> = {...prev};
Object.entries(panelRefs.current).forEach(([id, ref]) => {
if (ref) {
next[id] = ref.getBoundingClientRect();
}
});
return next;
});
};
useEffect(() => {
const observers: ResizeObserver[] = [];
Object.entries(panelRefs.current).forEach(([id, ref]) => {
if (ref) {
const observer = new ResizeObserver(() => updatePanelRects());
observer.observe(ref);
observers.push(observer);
}
});
updatePanelRects();
return () => {
observers.forEach((observer) => observer.disconnect());
};
}, [allSplitScreenTab, currentTab, allTabs.length]);
const renderAllTerminals = () => {
const layoutStyles: Record<number, React.CSSProperties> = {};
const splitTabs = allTabs.filter((tab) => allSplitScreenTab.includes(tab.id));
const mainTab = allTabs.find((tab) => tab.id === currentTab);
const layoutTabs = [mainTab, ...splitTabs.filter((t) => t && t.id !== (mainTab && mainTab.id))].filter((t): t is Tab => !!t);
if (allSplitScreenTab.length === 0 && mainTab) {
layoutStyles[mainTab.id] = {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
zIndex: 20,
display: 'block',
pointerEvents: 'auto',
};
} else {
layoutTabs.forEach((tab) => {
const rect = panelRects[String(tab.id)];
if (rect) {
const parentRect = panelRefs.current['parent']?.getBoundingClientRect();
let top = rect.top, left = rect.left, width = rect.width, height = rect.height;
if (parentRect) {
top = rect.top - parentRect.top;
left = rect.left - parentRect.left;
}
layoutStyles[tab.id] = {
position: 'absolute',
top: top + 28,
left,
width,
height: height - 28,
zIndex: 20,
display: 'block',
pointerEvents: 'auto',
};
}
});
}
return (
<div ref={el => {
panelRefs.current['parent'] = el;
}} style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
zIndex: 1,
overflow: 'hidden'
}}>
{allTabs.map((tab) => {
const style = layoutStyles[tab.id]
? {...layoutStyles[tab.id], overflow: 'hidden'}
: {display: 'none', overflow: 'hidden'};
const isVisible = !!layoutStyles[tab.id];
return (
<div key={tab.id} style={style} data-terminal-id={tab.id}>
<TerminalComponent
key={tab.id}
ref={tab.terminalRef}
hostConfig={tab.hostConfig}
isVisible={isVisible}
title={tab.title}
showTitle={false}
splitScreen={allSplitScreenTab.length > 0}
/>
</div>
);
})}
</div>
);
};
const renderSplitOverlays = () => {
const splitTabs = allTabs.filter((tab) => allSplitScreenTab.includes(tab.id));
const mainTab = allTabs.find((tab) => tab.id === currentTab);
const layoutTabs = [mainTab, ...splitTabs.filter((t) => t && t.id !== (mainTab && mainTab.id))].filter((t): t is Tab => !!t);
if (allSplitScreenTab.length === 0) return null;
if (layoutTabs.length === 2) {
const [tab1, tab2] = layoutTabs;
return (
<div style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
zIndex: 10,
pointerEvents: 'none'
}}>
<ResizablePrimitive.PanelGroup
ref={el => {
panelGroupRefs.current['main'] = el;
}}
direction="horizontal"
className="h-full w-full"
id="main-horizontal"
>
<ResizablePanel key={tab1.id} defaultSize={50} minSize={20}
className="!overflow-hidden h-full w-full" id={`panel-${tab1.id}`} order={1}>
<div ref={el => {
panelRefs.current[String(tab1.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
margin: 0,
padding: 0,
position: 'relative'
}}>
<div style={{
background: '#18181b',
color: '#fff',
fontSize: 13,
height: 28,
lineHeight: '28px',
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
}}>{tab1.title}</div>
</div>
</ResizablePanel>
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
<ResizablePanel key={tab2.id} defaultSize={50} minSize={20}
className="!overflow-hidden h-full w-full" id={`panel-${tab2.id}`} order={2}>
<div ref={el => {
panelRefs.current[String(tab2.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
margin: 0,
padding: 0,
position: 'relative'
}}>
<div style={{
background: '#18181b',
color: '#fff',
fontSize: 13,
height: 28,
lineHeight: '28px',
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
}}>{tab2.title}</div>
</div>
</ResizablePanel>
</ResizablePrimitive.PanelGroup>
</div>
);
}
if (layoutTabs.length === 3) {
return (
<div style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
zIndex: 10,
pointerEvents: 'none'
}}>
<ResizablePrimitive.PanelGroup
ref={el => {
panelGroupRefs.current['main'] = el;
}}
direction="vertical"
className="h-full w-full"
id="main-vertical"
>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id="top-panel" order={1}>
<ResizablePanelGroup ref={el => {
panelGroupRefs.current['top'] = el;
}} direction="horizontal" className="h-full w-full" id="top-horizontal">
<ResizablePanel key={layoutTabs[0].id} defaultSize={50} minSize={20}
className="!overflow-hidden h-full w-full"
id={`panel-${layoutTabs[0].id}`} order={1}>
<div ref={el => {
panelRefs.current[String(layoutTabs[0].id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
margin: 0,
padding: 0,
position: 'relative'
}}>
<div style={{
background: '#18181b',
color: '#fff',
fontSize: 13,
height: 28,
lineHeight: '28px',
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
}}>{layoutTabs[0].title}</div>
</div>
</ResizablePanel>
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
<ResizablePanel key={layoutTabs[1].id} defaultSize={50} minSize={20}
className="!overflow-hidden h-full w-full"
id={`panel-${layoutTabs[1].id}`} order={2}>
<div ref={el => {
panelRefs.current[String(layoutTabs[1].id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
margin: 0,
padding: 0,
position: 'relative'
}}>
<div style={{
background: '#18181b',
color: '#fff',
fontSize: 13,
height: 28,
lineHeight: '28px',
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
}}>{layoutTabs[1].title}</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id="bottom-panel" order={2}>
<div ref={el => {
panelRefs.current[String(layoutTabs[2].id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
margin: 0,
padding: 0,
position: 'relative'
}}>
<div style={{
background: '#18181b',
color: '#fff',
fontSize: 13,
height: 28,
lineHeight: '28px',
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
}}>{layoutTabs[2].title}</div>
</div>
</ResizablePanel>
</ResizablePrimitive.PanelGroup>
</div>
);
}
if (layoutTabs.length === 4) {
return (
<div style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
zIndex: 10,
pointerEvents: 'none'
}}>
<ResizablePrimitive.PanelGroup
ref={el => {
panelGroupRefs.current['main'] = el;
}}
direction="vertical"
className="h-full w-full"
id="main-vertical"
>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id="top-panel" order={1}>
<ResizablePanelGroup ref={el => {
panelGroupRefs.current['top'] = el;
}} direction="horizontal" className="h-full w-full" id="top-horizontal">
<ResizablePanel key={layoutTabs[0].id} defaultSize={50} minSize={20}
className="!overflow-hidden h-full w-full"
id={`panel-${layoutTabs[0].id}`} order={1}>
<div ref={el => {
panelRefs.current[String(layoutTabs[0].id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
margin: 0,
padding: 0,
position: 'relative'
}}>
<div style={{
background: '#18181b',
color: '#fff',
fontSize: 13,
height: 28,
lineHeight: '28px',
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
}}>{layoutTabs[0].title}</div>
</div>
</ResizablePanel>
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
<ResizablePanel key={layoutTabs[1].id} defaultSize={50} minSize={20}
className="!overflow-hidden h-full w-full"
id={`panel-${layoutTabs[1].id}`} order={2}>
<div ref={el => {
panelRefs.current[String(layoutTabs[1].id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
margin: 0,
padding: 0,
position: 'relative'
}}>
<div style={{
background: '#18181b',
color: '#fff',
fontSize: 13,
height: 28,
lineHeight: '28px',
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
}}>{layoutTabs[1].title}</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id="bottom-panel" order={2}>
<ResizablePanelGroup ref={el => {
panelGroupRefs.current['bottom'] = el;
}} direction="horizontal" className="h-full w-full" id="bottom-horizontal">
<ResizablePanel key={layoutTabs[2].id} defaultSize={50} minSize={20}
className="!overflow-hidden h-full w-full"
id={`panel-${layoutTabs[2].id}`} order={1}>
<div ref={el => {
panelRefs.current[String(layoutTabs[2].id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
margin: 0,
padding: 0,
position: 'relative'
}}>
<div style={{
background: '#18181b',
color: '#fff',
fontSize: 13,
height: 28,
lineHeight: '28px',
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
}}>{layoutTabs[2].title}</div>
</div>
</ResizablePanel>
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
<ResizablePanel key={layoutTabs[3].id} defaultSize={50} minSize={20}
className="!overflow-hidden h-full w-full"
id={`panel-${layoutTabs[3].id}`} order={2}>
<div ref={el => {
panelRefs.current[String(layoutTabs[3].id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
margin: 0,
padding: 0,
position: 'relative'
}}>
<div style={{
background: '#18181b',
color: '#fff',
fontSize: 13,
height: 28,
lineHeight: '28px',
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
}}>{layoutTabs[3].title}</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
</ResizablePrimitive.PanelGroup>
</div>
);
}
return null;
};
const onAddHostSubmit = (data: any) => {
const id = nextTabId.current++;
const title = `${data.ip || "Host"}:${data.port || 22}`;
const terminalRef = React.createRef<any>();
const newTab: Tab = {
id,
title,
hostConfig: data,
terminalRef,
};
setAllTabs((prev) => [...prev, newTab]);
setCurrentTab(id);
setAllSplitScreenTab((prev) => prev.filter((tid) => tid !== id));
};
const getUniqueTabTitle = (baseTitle: string) => {
let title = baseTitle;
let count = 1;
const existingTitles = allTabs.map(t => t.title);
while (existingTitles.includes(title)) {
title = `${baseTitle} (${count})`;
count++;
}
return title;
};
const onHostConnect = (hostConfig: any) => {
const baseTitle = hostConfig.name?.trim() ? hostConfig.name : `${hostConfig.ip || "Host"}:${hostConfig.port || 22}`;
const title = getUniqueTabTitle(baseTitle);
const terminalRef = React.createRef<any>();
const id = nextTabId.current++;
const newTab: Tab = {
id,
title,
hostConfig,
terminalRef,
};
setAllTabs((prev) => [...prev, newTab]);
setCurrentTab(id);
setAllSplitScreenTab((prev) => prev.filter((tid) => tid !== id));
};
return (
<div style={{display: 'flex', width: '100vw', height: '100vh', overflow: 'hidden', position: 'relative'}}>
<div
style={{
width: isSidebarOpen ? SIDEBAR_WIDTH : 0,
flexShrink: 0,
height: '100vh',
position: 'relative',
zIndex: 2,
margin: 0,
padding: 0,
border: 'none',
overflow: 'hidden',
transition: 'width 240ms ease-in-out',
willChange: 'width',
}}
>
<TerminalSidebar
onSelectView={onSelectView}
onHostConnect={onHostConnect}
allTabs={allTabs}
runCommandOnTabs={(tabIds: number[], command: string) => {
allTabs.forEach(tab => {
if (tabIds.includes(tab.id) && tab.terminalRef?.current?.sendInput) {
tab.terminalRef.current.sendInput(command);
}
});
}}
onCloseSidebar={() => setIsSidebarOpen(false)}
open={isSidebarOpen}
onOpenChange={setIsSidebarOpen}
/>
</div>
<div
className="terminal-container"
style={{
flex: 1,
height: '100vh',
position: 'relative',
overflow: 'hidden',
margin: 0,
padding: 0,
paddingLeft: isSidebarOpen ? 0 : HANDLE_THICKNESS,
paddingTop: isTopbarOpen ? 0 : HANDLE_THICKNESS,
border: 'none',
transition: 'padding-left 240ms ease-in-out, padding-top 240ms ease-in-out',
willChange: 'padding',
}}
>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: isTopbarOpen ? 46 : 0,
overflow: 'hidden',
zIndex: 10,
transition: 'height 240ms ease-in-out',
willChange: 'height',
}}
>
<TerminalTopbar
allTabs={allTabs}
currentTab={currentTab ?? -1}
setActiveTab={setActiveTab}
allSplitScreenTab={allSplitScreenTab}
setSplitScreenTab={setSplitScreenTab}
setCloseTab={setCloseTab}
onHideTopbar={() => setIsTopbarOpen(false)}
/>
</div>
{!isTopbarOpen && (
<div
onClick={() => setIsTopbarOpen(true)}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: HANDLE_THICKNESS,
background: '#222224',
cursor: 'pointer',
zIndex: 12,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
title="Show top bar">
<ChevronDown size={HANDLE_THICKNESS} />
</div>
)}
<div
style={{
height: isTopbarOpen ? 'calc(100% - 46px)' : '100%',
marginTop: isTopbarOpen ? 46 : 0,
position: 'relative',
transition: 'margin-top 240ms ease-in-out, height 240ms ease-in-out',
}}
>
{allTabs.length === 0 && (
<div style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
background: '#18181b',
border: '1px solid #434345',
borderRadius: '8px',
padding: '24px',
textAlign: 'center',
color: '#f7f7f7',
maxWidth: '400px',
zIndex: 30
}}>
<div style={{fontSize: '18px', fontWeight: 'bold', marginBottom: '12px'}}>
Welcome to Termix SSH
</div>
<div style={{fontSize: '14px', color: '#a1a1aa', lineHeight: '1.5'}}>
Click on any host title in the sidebar to open a terminal connection, or use the "Add
Host" button to create a new connection.
</div>
</div>
)}
{allSplitScreenTab.length > 0 && (
<div style={{position: 'absolute', top: 0, right: 0, zIndex: 20, height: 28}}>
<button
style={{
background: '#18181b',
color: '#fff',
borderLeft: '1px solid #222224',
borderRight: '1px solid #222224',
borderTop: 'none',
borderBottom: '1px solid #222224',
borderRadius: 0,
padding: '2px 10px',
cursor: 'pointer',
fontSize: 13,
margin: 0,
height: 28,
display: 'flex',
alignItems: 'center',
}}
onClick={() => {
if (allSplitScreenTab.length === 1) {
panelGroupRefs.current['main']?.setLayout([50, 50]);
} else if (allSplitScreenTab.length === 2) {
panelGroupRefs.current['main']?.setLayout([50, 50]);
panelGroupRefs.current['top']?.setLayout([50, 50]);
} else if (allSplitScreenTab.length === 3) {
panelGroupRefs.current['main']?.setLayout([50, 50]);
panelGroupRefs.current['top']?.setLayout([50, 50]);
panelGroupRefs.current['bottom']?.setLayout([50, 50]);
}
}}
>
Reset Split Sizes
</button>
</div>
)}
{renderAllTerminals()}
{renderSplitOverlays()}
</div>
</div>
{!isSidebarOpen && (
<div
onClick={() => setIsSidebarOpen(true)}
style={{
position: 'absolute',
top: 0,
left: 0,
width: HANDLE_THICKNESS,
height: '100%',
background: '#222224',
cursor: 'pointer',
zIndex: 20,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
title="Show sidebar">
<ChevronRight size={HANDLE_THICKNESS} />
</div>
)}
</div>
);
}

View File

@@ -1,440 +0,0 @@
import React, {useState} from 'react';
import {
CornerDownLeft,
Hammer, Pin, Menu
} from "lucide-react"
import {
Button
} from "@/components/ui/button.tsx"
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuItem, SidebarProvider,
} from "@/components/ui/sidebar.tsx"
import {
Separator,
} from "@/components/ui/separator.tsx"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger
} from "@/components/ui/sheet.tsx";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion.tsx";
import {ScrollArea} from "@/components/ui/scroll-area.tsx";
import {Input} from "@/components/ui/input.tsx";
import {getSSHHosts} from "@/apps/SSH/ssh-axios";
import {Checkbox} from "@/components/ui/checkbox.tsx";
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableConfigEditor: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
updatedAt: string;
}
export interface SidebarProps {
onSelectView: (view: string) => void;
onHostConnect: (hostConfig: any) => void;
allTabs: { id: number; title: string; terminalRef: React.RefObject<any> }[];
runCommandOnTabs: (tabIds: number[], command: string) => void;
onCloseSidebar?: () => void;
onAddHostSubmit?: (data: any) => void;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
export function TerminalSidebar({
onSelectView,
onHostConnect,
allTabs,
runCommandOnTabs,
onCloseSidebar,
open,
onOpenChange
}: SidebarProps): React.ReactElement {
const [hosts, setHosts] = useState<SSHHost[]>([]);
const [hostsLoading, setHostsLoading] = useState(false);
const [hostsError, setHostsError] = useState<string | null>(null);
const prevHostsRef = React.useRef<SSHHost[]>([]);
const fetchHosts = React.useCallback(async () => {
setHostsLoading(true);
setHostsError(null);
try {
const newHosts = await getSSHHosts();
const terminalHosts = newHosts.filter(host => host.enableTerminal);
const prevHosts = prevHostsRef.current;
const isSame =
terminalHosts.length === prevHosts.length &&
terminalHosts.every((h: SSHHost, i: number) => {
const prev = prevHosts[i];
if (!prev) return false;
return (
h.id === prev.id &&
h.name === prev.name &&
h.folder === prev.folder &&
h.ip === prev.ip &&
h.port === prev.port &&
h.username === prev.username &&
h.password === prev.password &&
h.authType === prev.authType &&
h.key === prev.key &&
h.pin === prev.pin &&
JSON.stringify(h.tags) === JSON.stringify(prev.tags)
);
});
if (!isSame) {
setHosts(terminalHosts);
prevHostsRef.current = terminalHosts;
}
} catch (err: any) {
setHostsError('Failed to load hosts');
} finally {
setHostsLoading(false);
}
}, []);
React.useEffect(() => {
fetchHosts();
const interval = setInterval(fetchHosts, 10000);
return () => clearInterval(interval);
}, [fetchHosts]);
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
React.useEffect(() => {
const handler = setTimeout(() => setDebouncedSearch(search), 200);
return () => clearTimeout(handler);
}, [search]);
const filteredHosts = React.useMemo(() => {
if (!debouncedSearch.trim()) return hosts;
const q = debouncedSearch.trim().toLowerCase();
return hosts.filter(h => {
const searchableText = [
h.name || '',
h.username,
h.ip,
h.folder || '',
...(h.tags || []),
h.authType,
h.defaultPath || ''
].join(' ').toLowerCase();
return searchableText.includes(q);
});
}, [hosts, debouncedSearch]);
const hostsByFolder = React.useMemo(() => {
const map: Record<string, SSHHost[]> = {};
filteredHosts.forEach(h => {
const folder = h.folder && h.folder.trim() ? h.folder : 'No Folder';
if (!map[folder]) map[folder] = [];
map[folder].push(h);
});
return map;
}, [filteredHosts]);
const sortedFolders = React.useMemo(() => {
const folders = Object.keys(hostsByFolder);
folders.sort((a, b) => {
if (a === 'No Folder') return -1;
if (b === 'No Folder') return 1;
return a.localeCompare(b);
});
return folders;
}, [hostsByFolder]);
const getSortedHosts = (arr: SSHHost[]) => {
const pinned = arr.filter(h => h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
const rest = arr.filter(h => !h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
return [...pinned, ...rest];
};
const [toolsSheetOpen, setToolsSheetOpen] = useState(false);
const [toolsCommand, setToolsCommand] = useState("");
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
const handleTabToggle = (tabId: number) => {
setSelectedTabIds(prev => prev.includes(tabId) ? prev.filter(id => id !== tabId) : [...prev, tabId]);
};
const handleRunCommand = () => {
if (selectedTabIds.length && toolsCommand.trim()) {
let cmd = toolsCommand;
if (!cmd.endsWith("\n")) cmd += "\n";
runCommandOnTabs(selectedTabIds, cmd);
setToolsCommand("");
}
};
function getCookie(name: string) {
return document.cookie.split('; ').reduce((r, v) => {
const parts = v.split('=');
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
}, "");
}
const updateRightClickCopyPaste = (checked) => {
document.cookie = `rightClickCopyPaste=${checked}; expires=2147483647; path=/`;
}
return (
<SidebarProvider open={open} onOpenChange={onOpenChange}>
<Sidebar className="h-full flex flex-col overflow-hidden">
<SidebarContent className="flex flex-col flex-grow h-full overflow-hidden">
<SidebarGroup className="flex flex-col flex-grow h-full overflow-hidden">
<SidebarGroupLabel
className="text-lg font-bold text-white flex items-center justify-between gap-2 w-full">
<span>Termix / Terminal</span>
<button
type="button"
onClick={() => onCloseSidebar?.()}
title="Hide sidebar"
style={{
height: 28,
width: 28,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
background: 'hsl(240 5% 9%)',
color: 'hsl(240 5% 64.9%)',
border: '1px solid hsl(240 3.7% 15.9%)',
borderRadius: 6,
cursor: 'pointer',
}}
>
<Menu className="h-4 w-4"/>
</button>
</SidebarGroupLabel>
<Separator className="p-0.25 mt-1 mb-1"/>
<SidebarGroupContent className="flex flex-col flex-grow h-full overflow-hidden">
<SidebarMenu className="flex flex-col flex-grow h-full overflow-hidden">
<SidebarMenuItem key="Homepage">
<Button
className="w-full mt-2 mb-2 h-8"
onClick={() => onSelectView("homepage")}
variant="outline"
>
<CornerDownLeft/>
Return
</Button>
<Separator className="p-0.25 mt-1 mb-1"/>
</SidebarMenuItem>
<SidebarMenuItem key="Main" className="flex flex-col flex-grow overflow-hidden">
<div
className="w-full flex-grow rounded-md bg-[#09090b] border border-[#434345] overflow-hidden p-0 m-0 relative flex flex-col min-h-0">
<div className="w-full px-2 pt-2 pb-2 bg-[#09090b] z-10">
<Input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search hosts by name, username, IP, folder, tags..."
className="w-full h-8 text-sm bg-background border border-border rounded"
autoComplete="off"
/>
</div>
<div style={{display: 'flex', justifyContent: 'center'}}>
<Separator className="w-full h-px bg-[#434345] my-2"
style={{maxWidth: 213, margin: '0 auto'}}/>
</div>
{hostsError && (
<div className="px-2 py-1 mt-2">
<div
className="text-xs text-red-500 bg-red-500/10 rounded px-2 py-1 border border-red-500/20">{hostsError}</div>
</div>
)}
<div className="flex-1 min-h-0">
<ScrollArea className="w-full h-full">
<Accordion key={`host-accordion-${sortedFolders.length}`}
type="multiple" className="w-full"
defaultValue={sortedFolders.length > 0 ? sortedFolders : undefined}>
{sortedFolders.map((folder, idx) => (
<React.Fragment key={folder}>
<AccordionItem value={folder}
className={idx === 0 ? "mt-0 !border-b-transparent" : "mt-2 !border-b-transparent"}>
<AccordionTrigger
className="text-base font-semibold rounded-t-none px-3 py-2"
style={{marginTop: idx === 0 ? 0 : undefined}}>{folder}</AccordionTrigger>
<AccordionContent
className="flex flex-col gap-1 px-3 pb-2 pt-1">
{getSortedHosts(hostsByFolder[folder]).map(host => (
<div key={host.id}
className="w-full overflow-hidden">
<HostMenuItem
host={host}
onHostConnect={onHostConnect}
/>
</div>
))}
</AccordionContent>
</AccordionItem>
{idx < sortedFolders.length - 1 && (
<div
style={{display: 'flex', justifyContent: 'center'}}>
<Separator className="h-px bg-[#434345] my-1"
style={{width: 213}}/>
</div>
)}
</React.Fragment>
))}
</Accordion>
</ScrollArea>
</div>
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
<div className="bg-sidebar">
<Sheet open={toolsSheetOpen} onOpenChange={setToolsSheetOpen}>
<SheetTrigger asChild>
<Button
className="w-full h-8 mt-2"
variant="outline"
onClick={() => setToolsSheetOpen(true)}
>
<Hammer className="mr-2 h-4 w-4"/>
Tools
</Button>
</SheetTrigger>
<SheetContent side="left"
className="w-[256px] fixed top-0 left-0 h-full z-[100] flex flex-col">
<SheetHeader className="pb-0.5">
<SheetTitle>Tools</SheetTitle>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-2 pt-2">
<Accordion type="single" collapsible defaultValue="multiwindow">
<AccordionItem value="multiwindow">
<AccordionTrigger className="text-base font-semibold">Run multiwindow
commands</AccordionTrigger>
<AccordionContent>
<textarea
className="w-full min-h-[120px] max-h-48 rounded-md border border-input text-foreground p-2 text-sm font-mono resize-vertical focus:outline-none focus:ring-0"
placeholder="Enter command(s) to run on selected tabs..."
value={toolsCommand}
onChange={e => setToolsCommand(e.target.value)}
style={{
fontFamily: 'monospace',
marginBottom: 8,
background: '#141416'
}}
/>
<div className="flex flex-wrap gap-2 mb-2">
{allTabs.map(tab => (
<Button
key={tab.id}
type="button"
variant={selectedTabIds.includes(tab.id) ? "secondary" : "outline"}
size="sm"
className="rounded-full px-3 py-1 text-xs flex items-center gap-1"
onClick={() => handleTabToggle(tab.id)}
>
{tab.title}
</Button>
))}
</div>
<Button
className="w-full"
variant="outline"
onClick={handleRunCommand}
disabled={!toolsCommand.trim() || !selectedTabIds.length}
>
Run Command
</Button>
</AccordionContent>
</AccordionItem>
</Accordion>
<Separator className="p-0.25"/>
<div className="flex items-center space-x-2 mt-5">
<Checkbox id="enable-copy-paste" onCheckedChange={updateRightClickCopyPaste}
defaultChecked={getCookie("rightClickCopyPaste") === "true"}/>
<label
htmlFor="enable-paste"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Enable rightclick copy/paste
</label>
</div>
</div>
</SheetContent>
</Sheet>
</div>
</SidebarGroup>
</SidebarContent>
</Sidebar>
</SidebarProvider>
);
}
const HostMenuItem = React.memo(function HostMenuItem({host, onHostConnect}: {
host: SSHHost;
onHostConnect: (hostConfig: any) => void
}) {
const tags = Array.isArray(host.tags) ? host.tags : [];
const hasTags = tags.length > 0;
return (
<div className="relative group flex flex-col mb-1 w-full overflow-hidden">
<div className={`flex flex-col w-full rounded overflow-hidden border border-[#434345] bg-[#18181b] h-full`}>
<div className="flex w-full h-10">
<div
className="flex items-center h-full px-2 w-full hover:bg-muted transition-colors cursor-pointer"
onClick={() => onHostConnect(host)}
>
<div className="flex items-center w-full">
{host.pin &&
<Pin className="h-4.5 mr-1 w-4.5 mt-0.5 text-yellow-500 flex-shrink-0"/>
}
<span className="font-medium truncate">{host.name || host.ip}</span>
</div>
</div>
</div>
{hasTags && (
<div
className="border-t border-border bg-[#18181b] flex items-center gap-1 px-2 py-2 overflow-x-auto overflow-y-hidden scrollbar-thin scrollbar-thumb-muted-foreground/30 scrollbar-track-transparent"
style={{height: 30}}>
{tags.map((tag: string) => (
<span key={tag}
className="bg-muted-foreground/10 text-xs rounded-full px-2 py-0.5 text-muted-foreground whitespace-nowrap border border-border flex-shrink-0 hover:bg-muted transition-colors">
{tag}
</span>
))}
</div>
)}
</div>
</div>
);
});

View File

@@ -1,76 +0,0 @@
import React from "react";
import {Button} from "@/components/ui/button.tsx";
import {X, SeparatorVertical} from "lucide-react"
interface TerminalTab {
id: number;
title: string;
}
interface SSHTabListProps {
allTabs: TerminalTab[];
currentTab: number;
setActiveTab: (tab: number) => void;
allSplitScreenTab: number[];
setSplitScreenTab: (tab: number) => void;
setCloseTab: (tab: number) => void;
}
export function TerminalTabList({
allTabs,
currentTab,
setActiveTab,
allSplitScreenTab = [],
setSplitScreenTab,
setCloseTab,
}: SSHTabListProps): React.ReactElement {
const isSplitScreenActive = allSplitScreenTab.length > 0;
return (
<div className="inline-flex items-center h-full px-[0.5rem] overflow-x-auto">
{allTabs.map((terminal, index) => {
const isActive = terminal.id === currentTab;
const isSplit = allSplitScreenTab.includes(terminal.id);
const isSplitButtonDisabled =
(isActive && !isSplitScreenActive) ||
(allSplitScreenTab.length >= 3 && !isSplit);
return (
<div
key={terminal.id}
className={index < allTabs.length - 1 ? "mr-[0.5rem]" : ""}
>
<div className="inline-flex rounded-md shadow-sm" role="group">
<Button
onClick={() => setActiveTab(terminal.id)}
disabled={isSplit}
variant="outline"
className={`rounded-r-none ${isActive ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
>
{terminal.title}
</Button>
<Button
onClick={() => setSplitScreenTab(terminal.id)}
disabled={isSplitButtonDisabled || isActive}
variant="outline"
className="rounded-none p-0 !w-9 !h-9"
>
<SeparatorVertical className="!w-5 !h-5" strokeWidth={2.5}/>
</Button>
<Button
onClick={() => setCloseTab(terminal.id)}
disabled={(isSplitScreenActive && isActive) || isSplit}
variant="outline"
className="rounded-l-none p-0 !w-9 !h-9"
>
<X className="!w-5 !h-5" strokeWidth={2.5}/>
</Button>
</div>
</div>
);
})}
</div>
);
}

View File

@@ -1,73 +0,0 @@
import {TerminalTabList} from "@/apps/SSH/Terminal/TerminalTabList.tsx";
import React from "react";
import {ChevronUp} from "lucide-react";
interface TerminalTab {
id: number;
title: string;
}
interface SSHTopbarProps {
allTabs: TerminalTab[];
currentTab: number;
setActiveTab: (tab: number) => void;
allSplitScreenTab: number[];
setSplitScreenTab: (tab: number) => void;
setCloseTab: (tab: number) => void;
onHideTopbar?: () => void;
}
export function TerminalTopbar({
allTabs,
currentTab,
setActiveTab,
allSplitScreenTab,
setSplitScreenTab,
setCloseTab,
onHideTopbar
}: SSHTopbarProps): React.ReactElement {
return (
<div className="flex h-11.5 z-100" style={{
position: 'absolute',
left: 0,
width: '100%',
backgroundColor: '#18181b',
borderBottom: '1px solid #222224',
display: 'flex',
alignItems: 'center',
}}>
<div style={{flex: 1, minWidth: 0, height: '100%', overflowX: 'auto'}}>
<div style={{minWidth: 'max-content', height: '100%', paddingLeft: 8, overflowY: 'hidden'}}>
<TerminalTabList
allTabs={allTabs}
currentTab={currentTab}
setActiveTab={setActiveTab}
allSplitScreenTab={allSplitScreenTab}
setSplitScreenTab={setSplitScreenTab}
setCloseTab={setCloseTab}
/>
</div>
</div>
<div style={{flex: '0 0 auto', paddingRight: 8, paddingLeft: 16}}>
<button
onClick={() => onHideTopbar?.()}
style={{
height: 28,
width: 28,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
background: 'hsl(240 5% 9%)',
color: 'hsl(240 5% 64.9%)',
border: '1px solid hsl(240 3.7% 15.9%)',
borderRadius: 6,
cursor: 'pointer',
}}
title="Hide top bar"
>
<ChevronUp size={16} strokeWidth={2}/>
</button>
</div>
</div>
)
}

View File

@@ -1,59 +0,0 @@
import React from 'react';
import {
CornerDownLeft,
Settings
} from "lucide-react"
import {
Button
} from "@/components/ui/button.tsx"
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuItem, SidebarProvider,
} from "@/components/ui/sidebar.tsx"
import {
Separator,
} from "@/components/ui/separator.tsx"
interface SidebarProps {
onSelectView: (view: string) => void;
}
export function SSHTunnelSidebar({onSelectView}: SidebarProps): React.ReactElement {
return (
<SidebarProvider>
<Sidebar>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
Termix / Tunnel
</SidebarGroupLabel>
<Separator className="p-0.25 mt-1 mb-1"/>
<SidebarGroupContent className="flex flex-col flex-grow">
<SidebarMenu>
<SidebarMenuItem key={"Homepage"}>
<Button className="w-full mt-2 mb-2 h-8" onClick={() => onSelectView("homepage")}
variant="outline">
<CornerDownLeft className="h-4 w-4 mr-2"/>
Return
</Button>
<Separator className="p-0.25 mt-1 mb-1"/>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
</SidebarProvider>
)
}

View File

@@ -1,184 +0,0 @@
import React from "react";
import {SSHTunnelObject} from "./SSHTunnelObject.tsx";
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion.tsx";
import {Separator} from "@/components/ui/separator.tsx";
import {Input} from "@/components/ui/input.tsx";
import {Search} from "lucide-react";
interface TunnelConnection {
sourcePort: number;
endpointPort: number;
endpointHost: string;
maxRetries: number;
retryInterval: number;
autoStart: boolean;
}
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableConfigEditor: boolean;
defaultPath: string;
tunnelConnections: TunnelConnection[];
createdAt: string;
updatedAt: string;
}
interface TunnelStatus {
status: string;
reason?: string;
errorType?: string;
retryCount?: number;
maxRetries?: number;
nextRetryIn?: number;
retryExhausted?: boolean;
}
interface SSHTunnelViewerProps {
hosts: SSHHost[];
tunnelStatuses: Record<string, TunnelStatus>;
tunnelActions: Record<string, boolean>;
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>;
}
export function SSHTunnelViewer({
hosts = [],
tunnelStatuses = {},
tunnelActions = {},
onTunnelAction
}: SSHTunnelViewerProps): React.ReactElement {
const [searchQuery, setSearchQuery] = React.useState("");
const [debouncedSearch, setDebouncedSearch] = React.useState("");
React.useEffect(() => {
const handler = setTimeout(() => setDebouncedSearch(searchQuery), 200);
return () => clearTimeout(handler);
}, [searchQuery]);
const filteredHosts = React.useMemo(() => {
if (!debouncedSearch.trim()) return hosts;
const query = debouncedSearch.trim().toLowerCase();
return hosts.filter(host => {
const searchableText = [
host.name || '',
host.username,
host.ip,
host.folder || '',
...(host.tags || []),
host.authType,
host.defaultPath || ''
].join(' ').toLowerCase();
return searchableText.includes(query);
});
}, [hosts, debouncedSearch]);
const tunnelHosts = React.useMemo(() => {
return filteredHosts.filter(host =>
host.enableTunnel &&
host.tunnelConnections &&
host.tunnelConnections.length > 0
);
}, [filteredHosts]);
const hostsByFolder = React.useMemo(() => {
const map: Record<string, SSHHost[]> = {};
tunnelHosts.forEach(host => {
const folder = host.folder && host.folder.trim() ? host.folder : 'Uncategorized';
if (!map[folder]) map[folder] = [];
map[folder].push(host);
});
return map;
}, [tunnelHosts]);
const sortedFolders = React.useMemo(() => {
const folders = Object.keys(hostsByFolder);
folders.sort((a, b) => {
if (a === 'Uncategorized') return -1;
if (b === 'Uncategorized') return 1;
return a.localeCompare(b);
});
return folders;
}, [hostsByFolder]);
const getSortedHosts = (arr: SSHHost[]) => {
const pinned = arr.filter(h => h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
const rest = arr.filter(h => !h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
return [...pinned, ...rest];
};
return (
<div className="w-full p-6" style={{width: 'calc(100vw - 256px)', maxWidth: 'none'}}>
<div className="w-full min-w-0" style={{width: '100%', maxWidth: 'none'}}>
<div className="mb-6">
<h1 className="text-2xl font-bold text-foreground mb-2">
SSH Tunnels
</h1>
<p className="text-muted-foreground">
Manage your SSH tunnel connections
</p>
</div>
<div className="relative mb-3">
<Search
className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
<Input
placeholder="Search hosts by name, username, IP, folder, tags..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
{tunnelHosts.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<h3 className="text-lg font-semibold text-foreground mb-2">
No SSH Tunnels
</h3>
<p className="text-muted-foreground max-w-md">
{searchQuery.trim() ?
"No hosts match your search criteria." :
"Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel connections."
}
</p>
</div>
) : (
<Accordion type="multiple" className="w-full" defaultValue={sortedFolders}>
{sortedFolders.map((folder, idx) => (
<AccordionItem value={folder} key={`folder-${folder}`}
className={idx === 0 ? "mt-0" : "mt-2"}>
<AccordionTrigger className="text-base font-semibold rounded-t-none px-3 py-2"
style={{marginTop: idx === 0 ? 0 : undefined}}>
{folder}
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-1 px-3 pb-2 pt-1">
<div className="grid grid-cols-4 gap-6 w-full">
{getSortedHosts(hostsByFolder[folder]).map((host, hostIndex) => (
<div key={host.id} className="w-full">
<SSHTunnelObject
host={host}
tunnelStatuses={tunnelStatuses}
tunnelActions={tunnelActions}
onTunnelAction={onTunnelAction}
/>
</div>
))}
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
)}
</div>
</div>
);
}

View File

@@ -1,520 +0,0 @@
import axios from 'axios';
interface SSHHostData {
name?: string;
ip: string;
port: number;
username: string;
folder?: string;
tags?: string[];
pin?: boolean;
authType: 'password' | 'key';
password?: string;
key?: File | null;
keyPassword?: string;
keyType?: string;
enableTerminal?: boolean;
enableTunnel?: boolean;
enableConfigEditor?: boolean;
defaultPath?: string;
tunnelConnections?: any[];
}
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableConfigEditor: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
updatedAt: string;
}
interface TunnelConfig {
name: string;
hostName: string;
sourceIP: string;
sourceSSHPort: number;
sourceUsername: string;
sourcePassword?: string;
sourceAuthMethod: string;
sourceSSHKey?: string;
sourceKeyPassword?: string;
sourceKeyType?: string;
endpointIP: string;
endpointSSHPort: number;
endpointUsername: string;
endpointPassword?: string;
endpointAuthMethod: string;
endpointSSHKey?: string;
endpointKeyPassword?: string;
endpointKeyType?: string;
sourcePort: number;
endpointPort: number;
maxRetries: number;
retryInterval: number;
autoStart: boolean;
isPinned: boolean;
}
interface TunnelStatus {
status: string;
reason?: string;
errorType?: string;
retryCount?: number;
maxRetries?: number;
nextRetryIn?: number;
retryExhausted?: boolean;
}
interface ConfigEditorFile {
name: string;
path: string;
type?: 'file' | 'directory';
isSSH?: boolean;
sshSessionId?: string;
}
interface ConfigEditorShortcut {
name: string;
path: string;
}
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
const sshHostApi = axios.create({
baseURL: isLocalhost ? 'http://localhost:8081' : window.location.origin,
headers: {
'Content-Type': 'application/json',
},
});
const tunnelApi = axios.create({
baseURL: isLocalhost ? 'http://localhost:8083' : window.location.origin,
headers: {
'Content-Type': 'application/json',
},
});
const configEditorApi = axios.create({
baseURL: isLocalhost ? 'http://localhost:8084' : window.location.origin,
headers: {
'Content-Type': 'application/json',
}
})
function getCookie(name: string): string | undefined {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop()?.split(';').shift();
}
sshHostApi.interceptors.request.use((config) => {
const token = getCookie('jwt');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
tunnelApi.interceptors.request.use((config) => {
const token = getCookie('jwt');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
configEditorApi.interceptors.request.use((config) => {
const token = getCookie('jwt');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
export async function getSSHHosts(): Promise<SSHHost[]> {
try {
const response = await sshHostApi.get('/ssh/db/host');
return response.data;
} catch (error) {
throw error;
}
}
export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
try {
const submitData = {
name: hostData.name || '',
ip: hostData.ip,
port: parseInt(hostData.port.toString()) || 22,
username: hostData.username,
folder: hostData.folder || '',
tags: hostData.tags || [],
pin: hostData.pin || false,
authMethod: hostData.authType,
password: hostData.authType === 'password' ? hostData.password : '',
key: hostData.authType === 'key' ? hostData.key : null,
keyPassword: hostData.authType === 'key' ? hostData.keyPassword : '',
keyType: hostData.authType === 'key' ? hostData.keyType : '',
enableTerminal: hostData.enableTerminal !== false,
enableTunnel: hostData.enableTunnel !== false,
enableConfigEditor: hostData.enableConfigEditor !== false,
defaultPath: hostData.defaultPath || '/',
tunnelConnections: hostData.tunnelConnections || [],
};
if (!submitData.enableTunnel) {
submitData.tunnelConnections = [];
}
if (!submitData.enableConfigEditor) {
submitData.defaultPath = '';
}
if (hostData.authType === 'key' && hostData.key instanceof File) {
const formData = new FormData();
formData.append('key', hostData.key);
const dataWithoutFile = {...submitData};
delete dataWithoutFile.key;
formData.append('data', JSON.stringify(dataWithoutFile));
const response = await sshHostApi.post('/ssh/db/host', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
} else {
const response = await sshHostApi.post('/ssh/db/host', submitData);
return response.data;
}
} catch (error) {
throw error;
}
}
export async function updateSSHHost(hostId: number, hostData: SSHHostData): Promise<SSHHost> {
try {
const submitData = {
name: hostData.name || '',
ip: hostData.ip,
port: parseInt(hostData.port.toString()) || 22,
username: hostData.username,
folder: hostData.folder || '',
tags: hostData.tags || [],
pin: hostData.pin || false,
authMethod: hostData.authType,
password: hostData.authType === 'password' ? hostData.password : '',
key: hostData.authType === 'key' ? hostData.key : null,
keyPassword: hostData.authType === 'key' ? hostData.keyPassword : '',
keyType: hostData.authType === 'key' ? hostData.keyType : '',
enableTerminal: hostData.enableTerminal !== false,
enableTunnel: hostData.enableTunnel !== false,
enableConfigEditor: hostData.enableConfigEditor !== false,
defaultPath: hostData.defaultPath || '/',
tunnelConnections: hostData.tunnelConnections || [],
};
if (!submitData.enableTunnel) {
submitData.tunnelConnections = [];
}
if (!submitData.enableConfigEditor) {
submitData.defaultPath = '';
}
if (hostData.authType === 'key' && hostData.key instanceof File) {
const formData = new FormData();
formData.append('key', hostData.key);
const dataWithoutFile = {...submitData};
delete dataWithoutFile.key;
formData.append('data', JSON.stringify(dataWithoutFile));
const response = await sshHostApi.put(`/ssh/db/host/${hostId}`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
} else {
const response = await sshHostApi.put(`/ssh/db/host/${hostId}`, submitData);
return response.data;
}
} catch (error) {
throw error;
}
}
export async function deleteSSHHost(hostId: number): Promise<any> {
try {
const response = await sshHostApi.delete(`/ssh/db/host/${hostId}`);
return response.data;
} catch (error) {
throw error;
}
}
export async function getSSHHostById(hostId: number): Promise<SSHHost> {
try {
const response = await sshHostApi.get(`/ssh/db/host/${hostId}`);
return response.data;
} catch (error) {
throw error;
}
}
export async function getTunnelStatuses(): Promise<Record<string, TunnelStatus>> {
try {
const response = await tunnelApi.get('/ssh/tunnel/status');
return response.data || {};
} catch (error) {
throw error;
}
}
export async function getTunnelStatusByName(tunnelName: string): Promise<TunnelStatus | undefined> {
const statuses = await getTunnelStatuses();
return statuses[tunnelName];
}
export async function connectTunnel(tunnelConfig: TunnelConfig): Promise<any> {
try {
const response = await tunnelApi.post('/ssh/tunnel/connect', tunnelConfig);
return response.data;
} catch (error) {
throw error;
}
}
export async function disconnectTunnel(tunnelName: string): Promise<any> {
try {
const response = await tunnelApi.post('/ssh/tunnel/disconnect', {tunnelName});
return response.data;
} catch (error) {
throw error;
}
}
export async function cancelTunnel(tunnelName: string): Promise<any> {
try {
const response = await tunnelApi.post('/ssh/tunnel/cancel', {tunnelName});
return response.data;
} catch (error) {
throw error;
}
}
export async function getConfigEditorRecent(hostId: number): Promise<ConfigEditorFile[]> {
try {
const response = await sshHostApi.get(`/ssh/config_editor/recent?hostId=${hostId}`);
return response.data || [];
} catch (error) {
return [];
}
}
export async function addConfigEditorRecent(file: {
name: string;
path: string;
isSSH: boolean;
sshSessionId?: string;
hostId: number
}): Promise<any> {
try {
const response = await sshHostApi.post('/ssh/config_editor/recent', file);
return response.data;
} catch (error) {
throw error;
}
}
export async function removeConfigEditorRecent(file: {
name: string;
path: string;
isSSH: boolean;
sshSessionId?: string;
hostId: number
}): Promise<any> {
try {
const response = await sshHostApi.delete('/ssh/config_editor/recent', {data: file});
return response.data;
} catch (error) {
throw error;
}
}
export async function getConfigEditorPinned(hostId: number): Promise<ConfigEditorFile[]> {
try {
const response = await sshHostApi.get(`/ssh/config_editor/pinned?hostId=${hostId}`);
return response.data || [];
} catch (error) {
return [];
}
}
export async function addConfigEditorPinned(file: {
name: string;
path: string;
isSSH: boolean;
sshSessionId?: string;
hostId: number
}): Promise<any> {
try {
const response = await sshHostApi.post('/ssh/config_editor/pinned', file);
return response.data;
} catch (error) {
throw error;
}
}
export async function removeConfigEditorPinned(file: {
name: string;
path: string;
isSSH: boolean;
sshSessionId?: string;
hostId: number
}): Promise<any> {
try {
const response = await sshHostApi.delete('/ssh/config_editor/pinned', {data: file});
return response.data;
} catch (error) {
throw error;
}
}
export async function getConfigEditorShortcuts(hostId: number): Promise<ConfigEditorShortcut[]> {
try {
const response = await sshHostApi.get(`/ssh/config_editor/shortcuts?hostId=${hostId}`);
return response.data || [];
} catch (error) {
return [];
}
}
export async function addConfigEditorShortcut(shortcut: {
name: string;
path: string;
isSSH: boolean;
sshSessionId?: string;
hostId: number
}): Promise<any> {
try {
const response = await sshHostApi.post('/ssh/config_editor/shortcuts', shortcut);
return response.data;
} catch (error) {
throw error;
}
}
export async function removeConfigEditorShortcut(shortcut: {
name: string;
path: string;
isSSH: boolean;
sshSessionId?: string;
hostId: number
}): Promise<any> {
try {
const response = await sshHostApi.delete('/ssh/config_editor/shortcuts', {data: shortcut});
return response.data;
} catch (error) {
throw error;
}
}
export async function connectSSH(sessionId: string, config: {
ip: string;
port: number;
username: string;
password?: string;
sshKey?: string;
keyPassword?: string;
}): Promise<any> {
try {
const response = await configEditorApi.post('/ssh/config_editor/ssh/connect', {
sessionId,
...config
});
return response.data;
} catch (error) {
throw error;
}
}
export async function disconnectSSH(sessionId: string): Promise<any> {
try {
const response = await configEditorApi.post('/ssh/config_editor/ssh/disconnect', {sessionId});
return response.data;
} catch (error) {
throw error;
}
}
export async function getSSHStatus(sessionId: string): Promise<{ connected: boolean }> {
try {
const response = await configEditorApi.get('/ssh/config_editor/ssh/status', {
params: {sessionId}
});
return response.data;
} catch (error) {
throw error;
}
}
export async function listSSHFiles(sessionId: string, path: string): Promise<any[]> {
try {
const response = await configEditorApi.get('/ssh/config_editor/ssh/listFiles', {
params: {sessionId, path}
});
return response.data || [];
} catch (error) {
throw error;
}
}
export async function readSSHFile(sessionId: string, path: string): Promise<{ content: string; path: string }> {
try {
const response = await configEditorApi.get('/ssh/config_editor/ssh/readFile', {
params: {sessionId, path}
});
return response.data;
} catch (error) {
throw error;
}
}
export async function writeSSHFile(sessionId: string, path: string, content: string): Promise<any> {
try {
const response = await configEditorApi.post('/ssh/config_editor/ssh/writeFile', {
sessionId,
path,
content
});
if (response.data && (response.data.message === 'File written successfully' || response.status === 200)) {
return response.data;
} else {
throw new Error('File write operation did not return success status');
}
} catch (error) {
throw error;
}
}
export {sshHostApi, tunnelApi, configEditorApi};

View File

@@ -1,18 +0,0 @@
import React from "react";
import {TemplateSidebar} from "@/apps/Template/TemplateSidebar.tsx";
interface ConfigEditorProps {
onSelectView: (view: string) => void;
}
export function Template({onSelectView}: ConfigEditorProps): React.ReactElement {
return (
<div>
<TemplateSidebar
onSelectView={onSelectView}
/>
Template
</div>
)
}

View File

@@ -1,58 +0,0 @@
import React from 'react';
import {
CornerDownLeft
} from "lucide-react"
import {
Button
} from "@/components/ui/button.tsx"
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuItem, SidebarProvider,
} from "@/components/ui/sidebar.tsx"
import {
Separator,
} from "@/components/ui/separator.tsx"
interface SidebarProps {
onSelectView: (view: string) => void;
}
export function TemplateSidebar({onSelectView}: SidebarProps): React.ReactElement {
return (
<SidebarProvider>
<Sidebar>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
Termix / Template
</SidebarGroupLabel>
<Separator className="p-0.25 mt-1 mb-1"/>
<SidebarGroupContent className="flex flex-col flex-grow">
<SidebarMenu>
<SidebarMenuItem key={"Homepage"}>
<Button className="w-full mt-2 mb-2 h-8" onClick={() => onSelectView("homepage")}
variant="outline">
<CornerDownLeft/>
Return
</Button>
<Separator className="p-0.25 mt-1 mb-1"/>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
</SidebarProvider>
)
}

View File

@@ -1,439 +0,0 @@
import express from 'express';
import cors from 'cors';
import {Client as SSHClient} from 'ssh2';
import chalk from "chalk";
const app = express();
app.use(cors({
origin: '*',
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
app.use(express.json());
const sshIconSymbol = '📁';
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${sshIconSymbol}]`)} ${message}`;
};
const logger = {
info: (msg: string): void => {
console.log(formatMessage('info', chalk.cyan, msg));
},
warn: (msg: string): void => {
console.warn(formatMessage('warn', chalk.yellow, msg));
},
error: (msg: string, err?: unknown): void => {
console.error(formatMessage('error', chalk.redBright, msg));
if (err) console.error(err);
},
success: (msg: string): void => {
console.log(formatMessage('success', chalk.greenBright, msg));
},
debug: (msg: string): void => {
if (process.env.NODE_ENV !== 'production') {
console.debug(formatMessage('debug', chalk.magenta, msg));
}
}
};
interface SSHSession {
client: SSHClient;
isConnected: boolean;
lastActive: number;
timeout?: NodeJS.Timeout;
}
const sshSessions: Record<string, SSHSession> = {};
const SESSION_TIMEOUT_MS = 10 * 60 * 1000;
function cleanupSession(sessionId: string) {
const session = sshSessions[sessionId];
if (session) {
try {
session.client.end();
} catch {
}
clearTimeout(session.timeout);
delete sshSessions[sessionId];
}
}
function scheduleSessionCleanup(sessionId: string) {
const session = sshSessions[sessionId];
if (session) {
if (session.timeout) clearTimeout(session.timeout);
session.timeout = setTimeout(() => cleanupSession(sessionId), SESSION_TIMEOUT_MS);
}
}
app.post('/ssh/config_editor/ssh/connect', (req, res) => {
const {sessionId, ip, port, username, password, sshKey, keyPassword} = req.body;
if (!sessionId || !ip || !username || !port) {
return res.status(400).json({error: 'Missing SSH connection parameters'});
}
if (sshSessions[sessionId]?.isConnected) cleanupSession(sessionId);
const client = new SSHClient();
const config: any = {
host: ip,
port: port || 22,
username,
readyTimeout: 20000,
keepaliveInterval: 10000,
keepaliveCountMax: 3,
algorithms: {
kex: [
'diffie-hellman-group14-sha256',
'diffie-hellman-group14-sha1',
'diffie-hellman-group1-sha1',
'diffie-hellman-group-exchange-sha256',
'diffie-hellman-group-exchange-sha1',
'ecdh-sha2-nistp256',
'ecdh-sha2-nistp384',
'ecdh-sha2-nistp521'
],
cipher: [
'aes128-ctr',
'aes192-ctr',
'aes256-ctr',
'aes128-gcm@openssh.com',
'aes256-gcm@openssh.com',
'aes128-cbc',
'aes192-cbc',
'aes256-cbc',
'3des-cbc'
],
hmac: [
'hmac-sha2-256',
'hmac-sha2-512',
'hmac-sha1',
'hmac-md5'
],
compress: [
'none',
'zlib@openssh.com',
'zlib'
]
}
};
if (sshKey && sshKey.trim()) {
config.privateKey = sshKey;
if (keyPassword) config.passphrase = keyPassword;
} else if (password && password.trim()) {
config.password = password;
} else {
return res.status(400).json({error: 'Either password or SSH key must be provided'});
}
let responseSent = false;
client.on('ready', () => {
if (responseSent) return;
responseSent = true;
sshSessions[sessionId] = {client, isConnected: true, lastActive: Date.now()};
scheduleSessionCleanup(sessionId);
res.json({status: 'success', message: 'SSH connection established'});
});
client.on('error', (err) => {
if (responseSent) return;
responseSent = true;
logger.error(`SSH connection error for session ${sessionId}:`, err.message);
res.status(500).json({status: 'error', message: err.message});
});
client.on('close', () => {
if (sshSessions[sessionId]) sshSessions[sessionId].isConnected = false;
cleanupSession(sessionId);
});
client.connect(config);
});
app.post('/ssh/config_editor/ssh/disconnect', (req, res) => {
const {sessionId} = req.body;
cleanupSession(sessionId);
res.json({status: 'success', message: 'SSH connection disconnected'});
});
app.get('/ssh/config_editor/ssh/status', (req, res) => {
const sessionId = req.query.sessionId as string;
const isConnected = !!sshSessions[sessionId]?.isConnected;
res.json({status: 'success', connected: isConnected});
});
app.get('/ssh/config_editor/ssh/listFiles', (req, res) => {
const sessionId = req.query.sessionId as string;
const sshConn = sshSessions[sessionId];
const sshPath = decodeURIComponent((req.query.path as string) || '/');
if (!sessionId) {
return res.status(400).json({error: 'Session ID is required'});
}
if (!sshConn?.isConnected) {
return res.status(400).json({error: 'SSH connection not established'});
}
sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
sshConn.client.exec(`ls -la '${escapedPath}'`, (err, stream) => {
if (err) {
logger.error('SSH listFiles error:', err);
return res.status(500).json({error: err.message});
}
let data = '';
let errorData = '';
stream.on('data', (chunk: Buffer) => {
data += chunk.toString();
});
stream.stderr.on('data', (chunk: Buffer) => {
errorData += chunk.toString();
});
stream.on('close', (code) => {
if (code !== 0) {
logger.error(`SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
return res.status(500).json({error: `Command failed: ${errorData}`});
}
const lines = data.split('\n').filter(line => line.trim());
const files = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
const parts = line.split(/\s+/);
if (parts.length >= 9) {
const permissions = parts[0];
const name = parts.slice(8).join(' ');
const isDirectory = permissions.startsWith('d');
const isLink = permissions.startsWith('l');
if (name === '.' || name === '..') continue;
files.push({
name,
type: isDirectory ? 'directory' : (isLink ? 'link' : 'file')
});
}
}
res.json(files);
});
});
});
app.get('/ssh/config_editor/ssh/readFile', (req, res) => {
const sessionId = req.query.sessionId as string;
const sshConn = sshSessions[sessionId];
const filePath = decodeURIComponent(req.query.path as string);
if (!sessionId) {
return res.status(400).json({error: 'Session ID is required'});
}
if (!sshConn?.isConnected) {
return res.status(400).json({error: 'SSH connection not established'});
}
if (!filePath) {
return res.status(400).json({error: 'File path is required'});
}
sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
sshConn.client.exec(`cat '${escapedPath}'`, (err, stream) => {
if (err) {
logger.error('SSH readFile error:', err);
return res.status(500).json({error: err.message});
}
let data = '';
let errorData = '';
stream.on('data', (chunk: Buffer) => {
data += chunk.toString();
});
stream.stderr.on('data', (chunk: Buffer) => {
errorData += chunk.toString();
});
stream.on('close', (code) => {
if (code !== 0) {
logger.error(`SSH readFile command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
return res.status(500).json({error: `Command failed: ${errorData}`});
}
res.json({content: data, path: filePath});
});
});
});
app.post('/ssh/config_editor/ssh/writeFile', (req, res) => {
const {sessionId, path: filePath, content} = req.body;
const sshConn = sshSessions[sessionId];
if (!sessionId) {
return res.status(400).json({error: 'Session ID is required'});
}
if (!sshConn?.isConnected) {
return res.status(400).json({error: 'SSH connection not established'});
}
if (!filePath) {
return res.status(400).json({error: 'File path is required'});
}
if (content === undefined) {
return res.status(400).json({error: 'File content is required'});
}
sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
const tempFile = `/tmp/temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const escapedTempFile = tempFile.replace(/'/g, "'\"'\"'");
const escapedFilePath = filePath.replace(/'/g, "'\"'\"'");
const base64Content = Buffer.from(content, 'utf8').toString('base64');
const commandTimeout = setTimeout(() => {
logger.error(`SSH writeFile command timed out for session: ${sessionId}`);
if (!res.headersSent) {
res.status(500).json({error: 'SSH command timed out'});
}
}, 15000);
const checkCommand = `ls -la '${escapedFilePath}' 2>/dev/null || echo "File does not exist"`;
sshConn.client.exec(checkCommand, (checkErr, checkStream) => {
if (checkErr) {
return res.status(500).json({error: `File check failed: ${checkErr.message}`});
}
let checkResult = '';
checkStream.on('data', (chunk: Buffer) => {
checkResult += chunk.toString();
});
checkStream.on('close', (checkCode) => {
const writeCommand = `echo '${base64Content}' > '${escapedTempFile}' && base64 -d '${escapedTempFile}' > '${escapedFilePath}' && rm -f '${escapedTempFile}' && echo "SUCCESS" && exit 0`;
sshConn.client.exec(writeCommand, (err, stream) => {
if (err) {
clearTimeout(commandTimeout);
logger.error('SSH writeFile error:', err);
if (!res.headersSent) {
return res.status(500).json({error: err.message});
}
return;
}
let outputData = '';
let errorData = '';
stream.on('data', (chunk: Buffer) => {
outputData += chunk.toString();
});
stream.stderr.on('data', (chunk: Buffer) => {
errorData += chunk.toString();
if (chunk.toString().includes('Permission denied')) {
clearTimeout(commandTimeout);
logger.error(`Permission denied writing to file: ${filePath}`);
if (!res.headersSent) {
return res.status(403).json({
error: `Permission denied: Cannot write to ${filePath}. Check file ownership and permissions. Use 'ls -la ${filePath}' to verify.`
});
}
return;
}
});
stream.on('close', (code) => {
clearTimeout(commandTimeout);
if (outputData.includes('SUCCESS')) {
const verifyCommand = `ls -la '${escapedFilePath}' 2>/dev/null | awk '{print $5}'`;
sshConn.client.exec(verifyCommand, (verifyErr, verifyStream) => {
if (verifyErr) {
if (!res.headersSent) {
res.json({message: 'File written successfully', path: filePath});
}
return;
}
let verifyResult = '';
verifyStream.on('data', (chunk: Buffer) => {
verifyResult += chunk.toString();
});
verifyStream.on('close', (verifyCode) => {
const fileSize = Number(verifyResult.trim());
if (fileSize > 0) {
if (!res.headersSent) {
res.json({message: 'File written successfully', path: filePath});
}
} else {
if (!res.headersSent) {
res.status(500).json({error: 'File write operation may have failed - file appears empty'});
}
}
});
});
return;
}
if (code !== 0) {
logger.error(`SSH writeFile command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
if (!res.headersSent) {
return res.status(500).json({error: `Command failed: ${errorData}`});
}
return;
}
if (!res.headersSent) {
res.json({message: 'File written successfully', path: filePath});
}
});
stream.on('error', (streamErr) => {
clearTimeout(commandTimeout);
logger.error('SSH writeFile stream error:', streamErr);
if (!res.headersSent) {
res.status(500).json({error: `Stream error: ${streamErr.message}`});
}
});
});
});
});
});
process.on('SIGINT', () => {
Object.keys(sshSessions).forEach(cleanupSession);
process.exit(0);
});
process.on('SIGTERM', () => {
Object.keys(sshSessions).forEach(cleanupSession);
process.exit(0);
});
const PORT = 8084;
app.listen(PORT, () => {
});

View File

@@ -2,6 +2,7 @@ import express from 'express';
import bodyParser from 'body-parser';
import userRoutes from './routes/users.js';
import sshRoutes from './routes/ssh.js';
import alertRoutes from './routes/alerts.js';
import chalk from 'chalk';
import cors from 'cors';
import fetch from 'node-fetch';
@@ -101,10 +102,10 @@ interface GitHubRelease {
async function fetchGitHubAPI(endpoint: string, cacheKey: string): Promise<any> {
const cachedData = githubCache.get(cacheKey);
if (cachedData) {
return {
data: cachedData,
cached: true,
cache_age: Date.now() - cachedData.timestamp
return {
data: cachedData,
cached: true,
cache_age: Date.now() - cachedData.timestamp
};
}
@@ -124,10 +125,10 @@ async function fetchGitHubAPI(endpoint: string, cacheKey: string): Promise<any>
const data = await response.json();
githubCache.set(cacheKey, data);
return {
data: data,
cached: false
return {
data: data,
cached: false
};
} catch (error) {
logger.error(`Failed to fetch from GitHub API: ${endpoint}`, error);
@@ -227,12 +228,16 @@ app.get('/releases/rss', async (req, res) => {
res.json(response);
} catch (error) {
logger.error('Failed to generate RSS format', error)
res.status(500).json({ error: 'Failed to generate RSS format', details: error instanceof Error ? error.message : 'Unknown error' });
res.status(500).json({
error: 'Failed to generate RSS format',
details: error instanceof Error ? error.message : 'Unknown error'
});
}
});
app.use('/users', userRoutes);
app.use('/ssh', sshRoutes);
app.use('/alerts', alertRoutes);
app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.error('Unhandled error:', err);
@@ -240,4 +245,5 @@ app.use((err: unknown, req: express.Request, res: express.Response, next: expres
});
const PORT = 8081;
app.listen(PORT, () => {});
app.listen(PORT, () => {
});

View File

@@ -41,86 +41,340 @@ const dbPath = path.join(dataDir, 'db.sqlite');
const sqlite = new Database(dbPath);
sqlite.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
is_oidc INTEGER NOT NULL DEFAULT 0,
client_id TEXT NOT NULL,
client_secret TEXT NOT NULL,
issuer_url TEXT NOT NULL,
authorization_url TEXT NOT NULL,
token_url TEXT NOT NULL,
redirect_uri TEXT,
identifier_path TEXT NOT NULL,
name_path TEXT NOT NULL,
scopes TEXT NOT NULL
CREATE TABLE IF NOT EXISTS users
(
id
TEXT
PRIMARY
KEY,
username
TEXT
NOT
NULL,
password_hash
TEXT
NOT
NULL,
is_admin
INTEGER
NOT
NULL
DEFAULT
0,
is_oidc
INTEGER
NOT
NULL
DEFAULT
0,
client_id
TEXT
NOT
NULL,
client_secret
TEXT
NOT
NULL,
issuer_url
TEXT
NOT
NULL,
authorization_url
TEXT
NOT
NULL,
token_url
TEXT
NOT
NULL,
redirect_uri
TEXT,
identifier_path
TEXT
NOT
NULL,
name_path
TEXT
NOT
NULL,
scopes
TEXT
NOT
NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
CREATE TABLE IF NOT EXISTS settings
(
key
TEXT
PRIMARY
KEY,
value
TEXT
NOT
NULL
);
CREATE TABLE IF NOT EXISTS ssh_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT,
ip TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT NOT NULL,
folder TEXT,
tags TEXT,
pin INTEGER NOT NULL DEFAULT 0,
auth_type TEXT NOT NULL,
password TEXT,
key TEXT,
key_password TEXT,
key_type TEXT,
enable_terminal INTEGER NOT NULL DEFAULT 1,
enable_tunnel INTEGER NOT NULL DEFAULT 1,
tunnel_connections TEXT,
enable_config_editor INTEGER NOT NULL DEFAULT 1,
default_path TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS ssh_data
(
id
INTEGER
PRIMARY
KEY
AUTOINCREMENT,
user_id
TEXT
NOT
NULL,
name
TEXT,
ip
TEXT
NOT
NULL,
port
INTEGER
NOT
NULL,
username
TEXT
NOT
NULL,
folder
TEXT,
tags
TEXT,
pin
INTEGER
NOT
NULL
DEFAULT
0,
auth_type
TEXT
NOT
NULL,
password
TEXT,
key
TEXT,
key_password
TEXT,
key_type
TEXT,
enable_terminal
INTEGER
NOT
NULL
DEFAULT
1,
enable_tunnel
INTEGER
NOT
NULL
DEFAULT
1,
tunnel_connections
TEXT,
enable_file_manager
INTEGER
NOT
NULL
DEFAULT
1,
default_path
TEXT,
created_at
TEXT
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP,
updated_at
TEXT
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP,
FOREIGN
KEY
(
user_id
) REFERENCES users
(
id
)
);
CREATE TABLE IF NOT EXISTS config_editor_recent (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (host_id) REFERENCES ssh_data(id)
);
CREATE TABLE IF NOT EXISTS file_manager_recent
(
id
INTEGER
PRIMARY
KEY
AUTOINCREMENT,
user_id
TEXT
NOT
NULL,
host_id
INTEGER
NOT
NULL,
name
TEXT
NOT
NULL,
path
TEXT
NOT
NULL,
last_opened
TEXT
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP,
FOREIGN
KEY
(
user_id
) REFERENCES users
(
id
),
FOREIGN KEY
(
host_id
) REFERENCES ssh_data
(
id
)
);
CREATE TABLE IF NOT EXISTS config_editor_pinned (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (host_id) REFERENCES ssh_data(id)
);
CREATE TABLE IF NOT EXISTS file_manager_pinned
(
id
INTEGER
PRIMARY
KEY
AUTOINCREMENT,
user_id
TEXT
NOT
NULL,
host_id
INTEGER
NOT
NULL,
name
TEXT
NOT
NULL,
path
TEXT
NOT
NULL,
pinned_at
TEXT
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP,
FOREIGN
KEY
(
user_id
) REFERENCES users
(
id
),
FOREIGN KEY
(
host_id
) REFERENCES ssh_data
(
id
)
);
CREATE TABLE IF NOT EXISTS config_editor_shortcuts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (host_id) REFERENCES ssh_data(id)
);
CREATE TABLE IF NOT EXISTS file_manager_shortcuts
(
id
INTEGER
PRIMARY
KEY
AUTOINCREMENT,
user_id
TEXT
NOT
NULL,
host_id
INTEGER
NOT
NULL,
name
TEXT
NOT
NULL,
path
TEXT
NOT
NULL,
created_at
TEXT
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP,
FOREIGN
KEY
(
user_id
) REFERENCES users
(
id
),
FOREIGN KEY
(
host_id
) REFERENCES ssh_data
(
id
)
);
CREATE TABLE IF NOT EXISTS dismissed_alerts
(
id
INTEGER
PRIMARY
KEY
AUTOINCREMENT,
user_id
TEXT
NOT
NULL,
alert_id
TEXT
NOT
NULL,
dismissed_at
TEXT
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP,
FOREIGN
KEY
(
user_id
) REFERENCES users
(
id
)
);
`);
const addColumnIfNotExists = (table: string, column: string, definition: string) => {
@@ -151,13 +405,17 @@ const migrateSchema = () => {
addColumnIfNotExists('users', 'token_url', 'TEXT');
try {
sqlite.prepare(`ALTER TABLE users DROP COLUMN redirect_uri`).run();
logger.info('Removed redirect_uri column from users table');
} catch (e) {
}
addColumnIfNotExists('users', 'identifier_path', 'TEXT');
addColumnIfNotExists('users', 'name_path', 'TEXT');
addColumnIfNotExists('users', 'scopes', 'TEXT');
// Add TOTP columns
addColumnIfNotExists('users', 'totp_secret', 'TEXT');
addColumnIfNotExists('users', 'totp_enabled', 'INTEGER NOT NULL DEFAULT 0');
addColumnIfNotExists('users', 'totp_backup_codes', 'TEXT');
addColumnIfNotExists('ssh_data', 'name', 'TEXT');
addColumnIfNotExists('ssh_data', 'folder', 'TEXT');
@@ -171,14 +429,14 @@ const migrateSchema = () => {
addColumnIfNotExists('ssh_data', 'enable_terminal', 'INTEGER NOT NULL DEFAULT 1');
addColumnIfNotExists('ssh_data', 'enable_tunnel', 'INTEGER NOT NULL DEFAULT 1');
addColumnIfNotExists('ssh_data', 'tunnel_connections', 'TEXT');
addColumnIfNotExists('ssh_data', 'enable_config_editor', 'INTEGER NOT NULL DEFAULT 1');
addColumnIfNotExists('ssh_data', 'enable_file_manager', 'INTEGER NOT NULL DEFAULT 1');
addColumnIfNotExists('ssh_data', 'default_path', 'TEXT');
addColumnIfNotExists('ssh_data', 'created_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
addColumnIfNotExists('ssh_data', 'updated_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
addColumnIfNotExists('config_editor_recent', 'host_id', 'INTEGER NOT NULL');
addColumnIfNotExists('config_editor_pinned', 'host_id', 'INTEGER NOT NULL');
addColumnIfNotExists('config_editor_shortcuts', 'host_id', 'INTEGER NOT NULL');
addColumnIfNotExists('file_manager_recent', 'host_id', 'INTEGER NOT NULL');
addColumnIfNotExists('file_manager_pinned', 'host_id', 'INTEGER NOT NULL');
addColumnIfNotExists('file_manager_shortcuts', 'host_id', 'INTEGER NOT NULL');
logger.success('Schema migration completed');
};

View File

@@ -17,6 +17,10 @@ export const users = sqliteTable('users', {
identifier_path: text('identifier_path'),
name_path: text('name_path'),
scopes: text().default("openid email profile"),
totp_secret: text('totp_secret'),
totp_enabled: integer('totp_enabled', {mode: 'boolean'}).notNull().default(false),
totp_backup_codes: text('totp_backup_codes'),
});
export const settings = sqliteTable('settings', {
@@ -42,13 +46,13 @@ export const sshData = sqliteTable('ssh_data', {
enableTerminal: integer('enable_terminal', {mode: 'boolean'}).notNull().default(true),
enableTunnel: integer('enable_tunnel', {mode: 'boolean'}).notNull().default(true),
tunnelConnections: text('tunnel_connections'),
enableConfigEditor: integer('enable_config_editor', {mode: 'boolean'}).notNull().default(true),
enableFileManager: integer('enable_file_manager', {mode: 'boolean'}).notNull().default(true),
defaultPath: text('default_path'),
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`),
});
export const configEditorRecent = sqliteTable('config_editor_recent', {
export const fileManagerRecent = sqliteTable('file_manager_recent', {
id: integer('id').primaryKey({autoIncrement: true}),
userId: text('user_id').notNull().references(() => users.id),
hostId: integer('host_id').notNull().references(() => sshData.id),
@@ -57,7 +61,7 @@ export const configEditorRecent = sqliteTable('config_editor_recent', {
lastOpened: text('last_opened').notNull().default(sql`CURRENT_TIMESTAMP`),
});
export const configEditorPinned = sqliteTable('config_editor_pinned', {
export const fileManagerPinned = sqliteTable('file_manager_pinned', {
id: integer('id').primaryKey({autoIncrement: true}),
userId: text('user_id').notNull().references(() => users.id),
hostId: integer('host_id').notNull().references(() => sshData.id),
@@ -66,11 +70,18 @@ export const configEditorPinned = sqliteTable('config_editor_pinned', {
pinnedAt: text('pinned_at').notNull().default(sql`CURRENT_TIMESTAMP`),
});
export const configEditorShortcuts = sqliteTable('config_editor_shortcuts', {
export const fileManagerShortcuts = sqliteTable('file_manager_shortcuts', {
id: integer('id').primaryKey({autoIncrement: true}),
userId: text('user_id').notNull().references(() => users.id),
hostId: integer('host_id').notNull().references(() => sshData.id),
name: text('name').notNull(),
path: text('path').notNull(),
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
});
export const dismissedAlerts = sqliteTable('dismissed_alerts', {
id: integer('id').primaryKey({autoIncrement: true}),
userId: text('user_id').notNull().references(() => users.id),
alertId: text('alert_id').notNull(),
dismissedAt: text('dismissed_at').notNull().default(sql`CURRENT_TIMESTAMP`),
});

View File

@@ -0,0 +1,270 @@
import express from 'express';
import {db} from '../db/index.js';
import {dismissedAlerts} from '../db/schema.js';
import {eq, and} from 'drizzle-orm';
import chalk from 'chalk';
import fetch from 'node-fetch';
import type {Request, Response, NextFunction} from 'express';
const dbIconSymbol = '🚨';
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#dc2626')(`[${dbIconSymbol}]`)} ${message}`;
};
const logger = {
info: (msg: string): void => {
console.log(formatMessage('info', chalk.cyan, msg));
},
warn: (msg: string): void => {
console.warn(formatMessage('warn', chalk.yellow, msg));
},
error: (msg: string, err?: unknown): void => {
console.error(formatMessage('error', chalk.redBright, msg));
if (err) console.error(err);
},
success: (msg: string): void => {
console.log(formatMessage('success', chalk.greenBright, msg));
},
debug: (msg: string): void => {
if (process.env.NODE_ENV !== 'production') {
console.debug(formatMessage('debug', chalk.magenta, msg));
}
}
};
interface CacheEntry {
data: any;
timestamp: number;
expiresAt: number;
}
class AlertCache {
private cache: Map<string, CacheEntry> = new Map();
private readonly CACHE_DURATION = 5 * 60 * 1000;
set(key: string, data: any): void {
const now = Date.now();
this.cache.set(key, {
data,
timestamp: now,
expiresAt: now + this.CACHE_DURATION
});
}
get(key: string): any | null {
const entry = this.cache.get(key);
if (!entry) {
return null;
}
if (Date.now() > entry.expiresAt) {
this.cache.delete(key);
return null;
}
return entry.data;
}
}
const alertCache = new AlertCache();
const GITHUB_RAW_BASE = 'https://raw.githubusercontent.com';
const REPO_OWNER = 'LukeGus';
const REPO_NAME = 'Termix-Docs';
const ALERTS_FILE = 'main/termix-alerts.json';
interface TermixAlert {
id: string;
title: string;
message: string;
expiresAt: string;
priority?: 'low' | 'medium' | 'high' | 'critical';
type?: 'info' | 'warning' | 'error' | 'success';
actionUrl?: string;
actionText?: string;
}
async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
const cacheKey = 'termix_alerts';
const cachedData = alertCache.get(cacheKey);
if (cachedData) {
return cachedData;
}
try {
const url = `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${ALERTS_FILE}`;
const response = await fetch(url, {
headers: {
'Accept': 'application/json',
'User-Agent': 'TermixAlertChecker/1.0'
}
});
if (!response.ok) {
throw new Error(`GitHub raw content error: ${response.status} ${response.statusText}`);
}
const alerts: TermixAlert[] = await response.json() as TermixAlert[];
const now = new Date();
const validAlerts = alerts.filter(alert => {
const expiryDate = new Date(alert.expiresAt);
const isValid = expiryDate > now;
return isValid;
});
alertCache.set(cacheKey, validAlerts);
return validAlerts;
} catch (error) {
logger.error('Failed to fetch alerts from GitHub', error);
return [];
}
}
const router = express.Router();
// Route: Get all active alerts
// GET /alerts
router.get('/', async (req, res) => {
try {
const alerts = await fetchAlertsFromGitHub();
res.json({
alerts,
cached: alertCache.get('termix_alerts') !== null,
total_count: alerts.length
});
} catch (error) {
logger.error('Failed to get alerts', error);
res.status(500).json({error: 'Failed to fetch alerts'});
}
});
// Route: Get alerts for a specific user (excluding dismissed ones)
// GET /alerts/user/:userId
router.get('/user/:userId', async (req, res) => {
try {
const {userId} = req.params;
if (!userId) {
return res.status(400).json({error: 'User ID is required'});
}
const allAlerts = await fetchAlertsFromGitHub();
const dismissedAlertRecords = await db
.select({alertId: dismissedAlerts.alertId})
.from(dismissedAlerts)
.where(eq(dismissedAlerts.userId, userId));
const dismissedAlertIds = new Set(dismissedAlertRecords.map(record => record.alertId));
const userAlerts = allAlerts.filter(alert => !dismissedAlertIds.has(alert.id));
res.json({
alerts: userAlerts,
total_count: userAlerts.length,
dismissed_count: dismissedAlertIds.size
});
} catch (error) {
logger.error('Failed to get user alerts', error);
res.status(500).json({error: 'Failed to fetch user alerts'});
}
});
// Route: Dismiss an alert for a user
// POST /alerts/dismiss
router.post('/dismiss', async (req, res) => {
try {
const {userId, alertId} = req.body;
if (!userId || !alertId) {
logger.warn('Missing userId or alertId in dismiss request');
return res.status(400).json({error: 'User ID and Alert ID are required'});
}
const existingDismissal = await db
.select()
.from(dismissedAlerts)
.where(and(
eq(dismissedAlerts.userId, userId),
eq(dismissedAlerts.alertId, alertId)
));
if (existingDismissal.length > 0) {
logger.warn(`Alert ${alertId} already dismissed by user ${userId}`);
return res.status(409).json({error: 'Alert already dismissed'});
}
const result = await db.insert(dismissedAlerts).values({
userId,
alertId
});
logger.success(`Alert ${alertId} dismissed by user ${userId}. Insert result: ${JSON.stringify(result)}`);
res.json({message: 'Alert dismissed successfully'});
} catch (error) {
logger.error('Failed to dismiss alert', error);
res.status(500).json({error: 'Failed to dismiss alert'});
}
});
// Route: Get dismissed alerts for a user
// GET /alerts/dismissed/:userId
router.get('/dismissed/:userId', async (req, res) => {
try {
const {userId} = req.params;
if (!userId) {
return res.status(400).json({error: 'User ID is required'});
}
const dismissedAlertRecords = await db
.select({
alertId: dismissedAlerts.alertId,
dismissedAt: dismissedAlerts.dismissedAt
})
.from(dismissedAlerts)
.where(eq(dismissedAlerts.userId, userId));
res.json({
dismissed_alerts: dismissedAlertRecords,
total_count: dismissedAlertRecords.length
});
} catch (error) {
logger.error('Failed to get dismissed alerts', error);
res.status(500).json({error: 'Failed to fetch dismissed alerts'});
}
});
// Route: Undismiss an alert for a user (remove from dismissed list)
// DELETE /alerts/dismiss
router.delete('/dismiss', async (req, res) => {
try {
const {userId, alertId} = req.body;
if (!userId || !alertId) {
return res.status(400).json({error: 'User ID and Alert ID are required'});
}
const result = await db
.delete(dismissedAlerts)
.where(and(
eq(dismissedAlerts.userId, userId),
eq(dismissedAlerts.alertId, alertId)
));
if (result.changes === 0) {
return res.status(404).json({error: 'Dismissed alert not found'});
}
logger.success(`Alert ${alertId} undismissed by user ${userId}`);
res.json({message: 'Alert undismissed successfully'});
} catch (error) {
logger.error('Failed to undismiss alert', error);
res.status(500).json({error: 'Failed to undismiss alert'});
}
});
export default router;

View File

@@ -1,6 +1,6 @@
import express from 'express';
import {db} from '../db/index.js';
import {sshData, configEditorRecent, configEditorPinned, configEditorShortcuts} from '../db/schema.js';
import {sshData, fileManagerRecent, fileManagerPinned, fileManagerShortcuts} from '../db/schema.js';
import {eq, and, desc} from 'drizzle-orm';
import chalk from 'chalk';
import jwt from 'jsonwebtoken';
@@ -94,7 +94,6 @@ router.get('/db/host/internal', async (req: Request, res: Response) => {
}
try {
const data = await db.select().from(sshData);
// Convert tags to array, booleans to bool, tunnelConnections to array
const result = data.map((row: any) => ({
...row,
tags: typeof row.tags === 'string' ? (row.tags ? row.tags.split(',').filter(Boolean) : []) : [],
@@ -102,7 +101,7 @@ router.get('/db/host/internal', async (req: Request, res: Response) => {
enableTerminal: !!row.enableTerminal,
enableTunnel: !!row.enableTunnel,
tunnelConnections: row.tunnelConnections ? JSON.parse(row.tunnelConnections) : [],
enableConfigEditor: !!row.enableConfigEditor,
enableFileManager: !!row.enableFileManager,
}));
res.json(result);
} catch (err) {
@@ -116,9 +115,7 @@ router.get('/db/host/internal', async (req: Request, res: Response) => {
router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => {
let hostData: any;
// Check if this is a multipart form data request (file upload)
if (req.headers['content-type']?.includes('multipart/form-data')) {
// Parse the JSON data from the 'data' field
if (req.body.data) {
try {
hostData = JSON.parse(req.body.data);
@@ -131,12 +128,10 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
return res.status(400).json({error: 'Missing data field'});
}
// Add the file data if present
if (req.file) {
hostData.key = req.file.buffer.toString('utf8');
}
} else {
// Regular JSON request
hostData = req.body;
}
@@ -155,7 +150,7 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
pin,
enableTerminal,
enableTunnel,
enableConfigEditor,
enableFileManager,
defaultPath,
tunnelConnections
} = hostData;
@@ -178,7 +173,7 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
enableTerminal: !!enableTerminal ? 1 : 0,
enableTunnel: !!enableTunnel ? 1 : 0,
tunnelConnections: Array.isArray(tunnelConnections) ? JSON.stringify(tunnelConnections) : null,
enableConfigEditor: !!enableConfigEditor ? 1 : 0,
enableFileManager: !!enableFileManager ? 1 : 0,
defaultPath: defaultPath || null,
};
@@ -243,7 +238,7 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re
pin,
enableTerminal,
enableTunnel,
enableConfigEditor,
enableFileManager,
defaultPath,
tunnelConnections
} = hostData;
@@ -266,7 +261,7 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re
enableTerminal: !!enableTerminal ? 1 : 0,
enableTunnel: !!enableTunnel ? 1 : 0,
tunnelConnections: Array.isArray(tunnelConnections) ? JSON.stringify(tunnelConnections) : null,
enableConfigEditor: !!enableConfigEditor ? 1 : 0,
enableFileManager: !!enableFileManager ? 1 : 0,
defaultPath: defaultPath || null,
};
@@ -313,7 +308,7 @@ router.get('/db/host', authenticateJWT, async (req: Request, res: Response) => {
enableTerminal: !!row.enableTerminal,
enableTunnel: !!row.enableTunnel,
tunnelConnections: row.tunnelConnections ? JSON.parse(row.tunnelConnections) : [],
enableConfigEditor: !!row.enableConfigEditor,
enableFileManager: !!row.enableFileManager,
}));
res.json(result);
} catch (err) {
@@ -351,7 +346,7 @@ router.get('/db/host/:id', authenticateJWT, async (req: Request, res: Response)
enableTerminal: !!host.enableTerminal,
enableTunnel: !!host.enableTunnel,
tunnelConnections: host.tunnelConnections ? JSON.parse(host.tunnelConnections) : [],
enableConfigEditor: !!host.enableConfigEditor,
enableFileManager: !!host.enableFileManager,
};
res.json(result);
@@ -411,8 +406,8 @@ router.delete('/db/host/:id', authenticateJWT, async (req: Request, res: Respons
});
// Route: Get recent files (requires JWT)
// GET /ssh/config_editor/recent
router.get('/config_editor/recent', authenticateJWT, async (req: Request, res: Response) => {
// GET /ssh/file_manager/recent
router.get('/file_manager/recent', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null;
@@ -429,12 +424,12 @@ router.get('/config_editor/recent', authenticateJWT, async (req: Request, res: R
try {
const recentFiles = await db
.select()
.from(configEditorRecent)
.from(fileManagerRecent)
.where(and(
eq(configEditorRecent.userId, userId),
eq(configEditorRecent.hostId, hostId)
eq(fileManagerRecent.userId, userId),
eq(fileManagerRecent.hostId, hostId)
))
.orderBy(desc(configEditorRecent.lastOpened));
.orderBy(desc(fileManagerRecent.lastOpened));
res.json(recentFiles);
} catch (err) {
logger.error('Failed to fetch recent files', err);
@@ -443,8 +438,8 @@ router.get('/config_editor/recent', authenticateJWT, async (req: Request, res: R
});
// Route: Add file to recent (requires JWT)
// POST /ssh/config_editor/recent
router.post('/config_editor/recent', authenticateJWT, async (req: Request, res: Response) => {
// POST /ssh/file_manager/recent
router.post('/file_manager/recent', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const {name, path, hostId} = req.body;
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
@@ -453,24 +448,23 @@ router.post('/config_editor/recent', authenticateJWT, async (req: Request, res:
}
try {
const conditions = [
eq(configEditorRecent.userId, userId),
eq(configEditorRecent.path, path),
eq(configEditorRecent.hostId, hostId)
eq(fileManagerRecent.userId, userId),
eq(fileManagerRecent.path, path),
eq(fileManagerRecent.hostId, hostId)
];
const existing = await db
.select()
.from(configEditorRecent)
.from(fileManagerRecent)
.where(and(...conditions));
if (existing.length > 0) {
await db
.update(configEditorRecent)
.update(fileManagerRecent)
.set({lastOpened: new Date().toISOString()})
.where(and(...conditions));
} else {
// Add new recent file
await db.insert(configEditorRecent).values({
await db.insert(fileManagerRecent).values({
userId,
hostId,
name,
@@ -486,8 +480,8 @@ router.post('/config_editor/recent', authenticateJWT, async (req: Request, res:
});
// Route: Remove file from recent (requires JWT)
// DELETE /ssh/config_editor/recent
router.delete('/config_editor/recent', authenticateJWT, async (req: Request, res: Response) => {
// DELETE /ssh/file_manager/recent
router.delete('/file_manager/recent', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const {name, path, hostId} = req.body;
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
@@ -496,13 +490,13 @@ router.delete('/config_editor/recent', authenticateJWT, async (req: Request, res
}
try {
const conditions = [
eq(configEditorRecent.userId, userId),
eq(configEditorRecent.path, path),
eq(configEditorRecent.hostId, hostId)
eq(fileManagerRecent.userId, userId),
eq(fileManagerRecent.path, path),
eq(fileManagerRecent.hostId, hostId)
];
const result = await db
.delete(configEditorRecent)
.delete(fileManagerRecent)
.where(and(...conditions));
res.json({message: 'File removed from recent'});
} catch (err) {
@@ -512,8 +506,8 @@ router.delete('/config_editor/recent', authenticateJWT, async (req: Request, res
});
// Route: Get pinned files (requires JWT)
// GET /ssh/config_editor/pinned
router.get('/config_editor/pinned', authenticateJWT, async (req: Request, res: Response) => {
// GET /ssh/file_manager/pinned
router.get('/file_manager/pinned', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null;
@@ -530,12 +524,12 @@ router.get('/config_editor/pinned', authenticateJWT, async (req: Request, res: R
try {
const pinnedFiles = await db
.select()
.from(configEditorPinned)
.from(fileManagerPinned)
.where(and(
eq(configEditorPinned.userId, userId),
eq(configEditorPinned.hostId, hostId)
eq(fileManagerPinned.userId, userId),
eq(fileManagerPinned.hostId, hostId)
))
.orderBy(configEditorPinned.pinnedAt);
.orderBy(fileManagerPinned.pinnedAt);
res.json(pinnedFiles);
} catch (err) {
logger.error('Failed to fetch pinned files', err);
@@ -544,8 +538,8 @@ router.get('/config_editor/pinned', authenticateJWT, async (req: Request, res: R
});
// Route: Add file to pinned (requires JWT)
// POST /ssh/config_editor/pinned
router.post('/config_editor/pinned', authenticateJWT, async (req: Request, res: Response) => {
// POST /ssh/file_manager/pinned
router.post('/file_manager/pinned', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const {name, path, hostId} = req.body;
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
@@ -554,18 +548,18 @@ router.post('/config_editor/pinned', authenticateJWT, async (req: Request, res:
}
try {
const conditions = [
eq(configEditorPinned.userId, userId),
eq(configEditorPinned.path, path),
eq(configEditorPinned.hostId, hostId)
eq(fileManagerPinned.userId, userId),
eq(fileManagerPinned.path, path),
eq(fileManagerPinned.hostId, hostId)
];
const existing = await db
.select()
.from(configEditorPinned)
.from(fileManagerPinned)
.where(and(...conditions));
if (existing.length === 0) {
await db.insert(configEditorPinned).values({
await db.insert(fileManagerPinned).values({
userId,
hostId,
name,
@@ -581,8 +575,8 @@ router.post('/config_editor/pinned', authenticateJWT, async (req: Request, res:
});
// Route: Remove file from pinned (requires JWT)
// DELETE /ssh/config_editor/pinned
router.delete('/config_editor/pinned', authenticateJWT, async (req: Request, res: Response) => {
// DELETE /ssh/file_manager/pinned
router.delete('/file_manager/pinned', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const {name, path, hostId} = req.body;
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
@@ -591,13 +585,13 @@ router.delete('/config_editor/pinned', authenticateJWT, async (req: Request, res
}
try {
const conditions = [
eq(configEditorPinned.userId, userId),
eq(configEditorPinned.path, path),
eq(configEditorPinned.hostId, hostId)
eq(fileManagerPinned.userId, userId),
eq(fileManagerPinned.path, path),
eq(fileManagerPinned.hostId, hostId)
];
const result = await db
.delete(configEditorPinned)
.delete(fileManagerPinned)
.where(and(...conditions));
res.json({message: 'File unpinned successfully'});
} catch (err) {
@@ -607,8 +601,8 @@ router.delete('/config_editor/pinned', authenticateJWT, async (req: Request, res
});
// Route: Get folder shortcuts (requires JWT)
// GET /ssh/config_editor/shortcuts
router.get('/config_editor/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
// GET /ssh/file_manager/shortcuts
router.get('/file_manager/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null;
@@ -623,12 +617,12 @@ router.get('/config_editor/shortcuts', authenticateJWT, async (req: Request, res
try {
const shortcuts = await db
.select()
.from(configEditorShortcuts)
.from(fileManagerShortcuts)
.where(and(
eq(configEditorShortcuts.userId, userId),
eq(configEditorShortcuts.hostId, hostId)
eq(fileManagerShortcuts.userId, userId),
eq(fileManagerShortcuts.hostId, hostId)
))
.orderBy(configEditorShortcuts.createdAt);
.orderBy(fileManagerShortcuts.createdAt);
res.json(shortcuts);
} catch (err) {
logger.error('Failed to fetch shortcuts', err);
@@ -637,8 +631,8 @@ router.get('/config_editor/shortcuts', authenticateJWT, async (req: Request, res
});
// Route: Add folder shortcut (requires JWT)
// POST /ssh/config_editor/shortcuts
router.post('/config_editor/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
// POST /ssh/file_manager/shortcuts
router.post('/file_manager/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const {name, path, hostId} = req.body;
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
@@ -646,18 +640,18 @@ router.post('/config_editor/shortcuts', authenticateJWT, async (req: Request, re
}
try {
const conditions = [
eq(configEditorShortcuts.userId, userId),
eq(configEditorShortcuts.path, path),
eq(configEditorShortcuts.hostId, hostId)
eq(fileManagerShortcuts.userId, userId),
eq(fileManagerShortcuts.path, path),
eq(fileManagerShortcuts.hostId, hostId)
];
const existing = await db
.select()
.from(configEditorShortcuts)
.from(fileManagerShortcuts)
.where(and(...conditions));
if (existing.length === 0) {
await db.insert(configEditorShortcuts).values({
await db.insert(fileManagerShortcuts).values({
userId,
hostId,
name,
@@ -673,8 +667,8 @@ router.post('/config_editor/shortcuts', authenticateJWT, async (req: Request, re
});
// Route: Remove folder shortcut (requires JWT)
// DELETE /ssh/config_editor/shortcuts
router.delete('/config_editor/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
// DELETE /ssh/file_manager/shortcuts
router.delete('/file_manager/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const {name, path, hostId} = req.body;
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
@@ -682,13 +676,13 @@ router.delete('/config_editor/shortcuts', authenticateJWT, async (req: Request,
}
try {
const conditions = [
eq(configEditorShortcuts.userId, userId),
eq(configEditorShortcuts.path, path),
eq(configEditorShortcuts.hostId, hostId)
eq(fileManagerShortcuts.userId, userId),
eq(fileManagerShortcuts.path, path),
eq(fileManagerShortcuts.hostId, hostId)
];
const result = await db
.delete(configEditorShortcuts)
.delete(fileManagerShortcuts)
.where(and(...conditions));
res.json({message: 'Shortcut removed successfully'});
} catch (err) {
@@ -697,4 +691,116 @@ router.delete('/config_editor/shortcuts', authenticateJWT, async (req: Request,
}
});
// Route: Bulk import SSH hosts from JSON (requires JWT)
// POST /ssh/bulk-import
router.post('/bulk-import', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const {hosts} = req.body;
if (!Array.isArray(hosts) || hosts.length === 0) {
logger.warn('Invalid bulk import data - hosts array is required and must not be empty');
return res.status(400).json({error: 'Hosts array is required and must not be empty'});
}
if (hosts.length > 100) {
logger.warn(`Bulk import attempted with too many hosts: ${hosts.length}`);
return res.status(400).json({error: 'Maximum 100 hosts allowed per import'});
}
const results = {
success: 0,
failed: 0,
errors: [] as string[]
};
for (let i = 0; i < hosts.length; i++) {
const hostData = hosts[i];
try {
if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port) || !isNonEmptyString(hostData.username)) {
results.failed++;
results.errors.push(`Host ${i + 1}: Missing or invalid required fields (ip, port, username)`);
continue;
}
if (hostData.authType !== 'password' && hostData.authType !== 'key') {
results.failed++;
results.errors.push(`Host ${i + 1}: Invalid authType. Must be 'password' or 'key'`);
continue;
}
if (hostData.authType === 'password' && !isNonEmptyString(hostData.password)) {
results.failed++;
results.errors.push(`Host ${i + 1}: Password required for password authentication`);
continue;
}
if (hostData.authType === 'key' && !isNonEmptyString(hostData.key)) {
results.failed++;
results.errors.push(`Host ${i + 1}: SSH key required for key authentication`);
continue;
}
if (hostData.enableTunnel && Array.isArray(hostData.tunnelConnections)) {
for (let j = 0; j < hostData.tunnelConnections.length; j++) {
const conn = hostData.tunnelConnections[j];
if (!isValidPort(conn.sourcePort) || !isValidPort(conn.endpointPort) || !isNonEmptyString(conn.endpointHost)) {
results.failed++;
results.errors.push(`Host ${i + 1}, Tunnel ${j + 1}: Invalid tunnel connection data`);
break;
}
}
}
const sshDataObj: any = {
userId: userId,
name: hostData.name || '',
folder: hostData.folder || '',
tags: Array.isArray(hostData.tags) ? hostData.tags.join(',') : (hostData.tags || ''),
ip: hostData.ip,
port: hostData.port,
username: hostData.username,
authType: hostData.authType,
pin: !!hostData.pin ? 1 : 0,
enableTerminal: !!hostData.enableTerminal ? 1 : 0,
enableTunnel: !!hostData.enableTunnel ? 1 : 0,
tunnelConnections: Array.isArray(hostData.tunnelConnections) ? JSON.stringify(hostData.tunnelConnections) : null,
enableFileManager: !!hostData.enableFileManager ? 1 : 0,
defaultPath: hostData.defaultPath || null,
};
if (hostData.authType === 'password') {
sshDataObj.password = hostData.password;
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.keyType = null;
} else if (hostData.authType === 'key') {
sshDataObj.key = hostData.key;
sshDataObj.keyPassword = hostData.keyPassword || null;
sshDataObj.keyType = hostData.keyType || null;
sshDataObj.password = null;
}
await db.insert(sshData).values(sshDataObj);
results.success++;
} catch (err) {
results.failed++;
results.errors.push(`Host ${i + 1}: ${err instanceof Error ? err.message : 'Unknown error'}`);
logger.error(`Failed to import host ${i + 1}:`, err);
}
}
if (results.success > 0) {
logger.success(`Bulk import completed: ${results.success} successful, ${results.failed} failed`);
} else {
logger.warn(`Bulk import failed: ${results.failed} failed`);
}
res.json({
message: `Import completed: ${results.success} successful, ${results.failed} failed`,
...results
});
});
export default router;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,454 @@
import express from 'express';
import chalk from 'chalk';
import fetch from 'node-fetch';
import net from 'net';
import cors from 'cors';
import {Client, type ConnectConfig} from 'ssh2';
type HostRecord = {
id: number;
ip: string;
port: number;
username?: string;
authType?: 'password' | 'key' | string;
password?: string | null;
key?: string | null;
keyPassword?: string | null;
keyType?: string | null;
};
type HostStatus = 'online' | 'offline';
type StatusEntry = {
status: HostStatus;
lastChecked: string;
};
const app = express();
app.use(cors({
origin: '*',
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
if (req.method === 'OPTIONS') {
return res.sendStatus(204);
}
next();
});
app.use(express.json());
const statsIconSymbol = '📡';
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#22c55e')(`[${statsIconSymbol}]`)} ${message}`;
};
const logger = {
info: (msg: string): void => {
console.log(formatMessage('info', chalk.cyan, msg));
},
warn: (msg: string): void => {
console.warn(formatMessage('warn', chalk.yellow, msg));
},
error: (msg: string, err?: unknown): void => {
console.error(formatMessage('error', chalk.redBright, msg));
if (err) console.error(err);
},
success: (msg: string): void => {
console.log(formatMessage('success', chalk.greenBright, msg));
},
debug: (msg: string): void => {
if (process.env.NODE_ENV !== 'production') {
console.debug(formatMessage('debug', chalk.magenta, msg));
}
}
};
const hostStatuses: Map<number, StatusEntry> = new Map();
async function fetchAllHosts(): Promise<HostRecord[]> {
const url = 'http://localhost:8081/ssh/db/host/internal';
try {
const resp = await fetch(url, {
headers: {'x-internal-request': '1'}
});
if (!resp.ok) {
throw new Error(`DB service error: ${resp.status} ${resp.statusText}`);
}
const data = await resp.json();
const hosts: HostRecord[] = (Array.isArray(data) ? data : []).map((h: any) => ({
id: Number(h.id),
ip: String(h.ip),
port: Number(h.port) || 22,
username: h.username,
authType: h.authType,
password: h.password ?? null,
key: h.key ?? null,
keyPassword: h.keyPassword ?? null,
keyType: h.keyType ?? null,
})).filter(h => !!h.id && !!h.ip && !!h.port);
return hosts;
} catch (err) {
logger.error('Failed to fetch hosts from database service', err);
return [];
}
}
async function fetchHostById(id: number): Promise<HostRecord | undefined> {
const all = await fetchAllHosts();
return all.find(h => h.id === id);
}
function buildSshConfig(host: HostRecord): ConnectConfig {
const base: ConnectConfig = {
host: host.ip,
port: host.port || 22,
username: host.username || 'root',
readyTimeout: 10_000,
algorithms: {}
} as ConnectConfig;
if (host.authType === 'password') {
(base as any).password = host.password || '';
} else if (host.authType === 'key') {
if (host.key) {
try {
if (!host.key.includes('-----BEGIN') || !host.key.includes('-----END')) {
throw new Error('Invalid private key format');
}
const cleanKey = host.key.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
(base as any).privateKey = Buffer.from(cleanKey, 'utf8');
if (host.keyPassword) {
(base as any).passphrase = host.keyPassword;
}
} catch (keyError) {
logger.error(`SSH key format error for host ${host.ip}: ${keyError.message}`);
if (host.password) {
(base as any).password = host.password;
} else {
throw new Error(`Invalid SSH key format for host ${host.ip}`);
}
}
}
}
return base;
}
async function withSshConnection<T>(host: HostRecord, fn: (client: Client) => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
const client = new Client();
let settled = false;
const onError = (err: Error) => {
if (!settled) {
settled = true;
try {
client.end();
} catch {
}
reject(err);
}
};
client.on('ready', async () => {
try {
const result = await fn(client);
if (!settled) {
settled = true;
try {
client.end();
} catch {
}
resolve(result);
}
} catch (err: any) {
onError(err);
}
});
client.on('error', onError);
client.on('timeout', () => onError(new Error('SSH connection timeout')));
try {
client.connect(buildSshConfig(host));
} catch (err: any) {
onError(err);
}
});
}
function execCommand(client: Client, command: string): Promise<{
stdout: string;
stderr: string;
code: number | null;
}> {
return new Promise((resolve, reject) => {
client.exec(command, {pty: false}, (err, stream) => {
if (err) return reject(err);
let stdout = '';
let stderr = '';
let exitCode: number | null = null;
stream.on('close', (code: number | undefined) => {
exitCode = typeof code === 'number' ? code : null;
resolve({stdout, stderr, code: exitCode});
}).on('data', (data: Buffer) => {
stdout += data.toString('utf8');
}).stderr.on('data', (data: Buffer) => {
stderr += data.toString('utf8');
});
});
});
}
function parseCpuLine(cpuLine: string): { total: number; idle: number } | undefined {
const parts = cpuLine.trim().split(/\s+/);
if (parts[0] !== 'cpu') return undefined;
const nums = parts.slice(1).map(n => Number(n)).filter(n => Number.isFinite(n));
if (nums.length < 4) return undefined;
const idle = (nums[3] ?? 0) + (nums[4] ?? 0);
const total = nums.reduce((a, b) => a + b, 0);
return {total, idle};
}
function toFixedNum(n: number | null | undefined, digits = 2): number | null {
if (typeof n !== 'number' || !Number.isFinite(n)) return null;
return Number(n.toFixed(digits));
}
function kibToGiB(kib: number): number {
return kib / (1024 * 1024);
}
async function collectMetrics(host: HostRecord): Promise<{
cpu: { percent: number | null; cores: number | null; load: [number, number, number] | null };
memory: { percent: number | null; usedGiB: number | null; totalGiB: number | null };
disk: { percent: number | null; usedHuman: string | null; totalHuman: string | null };
}> {
return withSshConnection(host, async (client) => {
let cpuPercent: number | null = null;
let cores: number | null = null;
let loadTriplet: [number, number, number] | null = null;
try {
const stat1 = await execCommand(client, 'cat /proc/stat');
await new Promise(r => setTimeout(r, 500));
const stat2 = await execCommand(client, 'cat /proc/stat');
const loadAvgOut = await execCommand(client, 'cat /proc/loadavg');
const coresOut = await execCommand(client, 'nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo');
const cpuLine1 = (stat1.stdout.split('\n').find(l => l.startsWith('cpu ')) || '').trim();
const cpuLine2 = (stat2.stdout.split('\n').find(l => l.startsWith('cpu ')) || '').trim();
const a = parseCpuLine(cpuLine1);
const b = parseCpuLine(cpuLine2);
if (a && b) {
const totalDiff = b.total - a.total;
const idleDiff = b.idle - a.idle;
const used = totalDiff - idleDiff;
if (totalDiff > 0) cpuPercent = Math.max(0, Math.min(100, (used / totalDiff) * 100));
}
const laParts = loadAvgOut.stdout.trim().split(/\s+/);
if (laParts.length >= 3) {
loadTriplet = [Number(laParts[0]), Number(laParts[1]), Number(laParts[2])].map(v => Number.isFinite(v) ? Number(v) : 0) as [number, number, number];
}
const coresNum = Number((coresOut.stdout || '').trim());
cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null;
} catch (e) {
cpuPercent = null;
cores = null;
loadTriplet = null;
}
let memPercent: number | null = null;
let usedGiB: number | null = null;
let totalGiB: number | null = null;
try {
const memInfo = await execCommand(client, 'cat /proc/meminfo');
const lines = memInfo.stdout.split('\n');
const getVal = (key: string) => {
const line = lines.find(l => l.startsWith(key));
if (!line) return null;
const m = line.match(/\d+/);
return m ? Number(m[0]) : null;
};
const totalKb = getVal('MemTotal:');
const availKb = getVal('MemAvailable:');
if (totalKb && availKb && totalKb > 0) {
const usedKb = totalKb - availKb;
memPercent = Math.max(0, Math.min(100, (usedKb / totalKb) * 100));
usedGiB = kibToGiB(usedKb);
totalGiB = kibToGiB(totalKb);
}
} catch (e) {
memPercent = null;
usedGiB = null;
totalGiB = null;
}
let diskPercent: number | null = null;
let usedHuman: string | null = null;
let totalHuman: string | null = null;
try {
// Get both human-readable and bytes format for accurate calculation
const diskOutHuman = await execCommand(client, 'df -h -P / | tail -n +2');
const diskOutBytes = await execCommand(client, 'df -B1 -P / | tail -n +2');
const humanLine = diskOutHuman.stdout.split('\n').map(l => l.trim()).filter(Boolean)[0] || '';
const bytesLine = diskOutBytes.stdout.split('\n').map(l => l.trim()).filter(Boolean)[0] || '';
const humanParts = humanLine.split(/\s+/);
const bytesParts = bytesLine.split(/\s+/);
if (humanParts.length >= 6 && bytesParts.length >= 6) {
totalHuman = humanParts[1] || null;
usedHuman = humanParts[2] || null;
// Calculate our own percentage using bytes for accuracy
const totalBytes = Number(bytesParts[1]);
const usedBytes = Number(bytesParts[2]);
if (Number.isFinite(totalBytes) && Number.isFinite(usedBytes) && totalBytes > 0) {
diskPercent = Math.max(0, Math.min(100, (usedBytes / totalBytes) * 100));
}
}
} catch (e) {
diskPercent = null;
usedHuman = null;
totalHuman = null;
}
return {
cpu: {percent: toFixedNum(cpuPercent, 0), cores, load: loadTriplet},
memory: {
percent: toFixedNum(memPercent, 0),
usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null,
totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null
},
disk: {percent: toFixedNum(diskPercent, 0), usedHuman, totalHuman},
};
});
}
function tcpPing(host: string, port: number, timeoutMs = 5000): Promise<boolean> {
return new Promise((resolve) => {
const socket = new net.Socket();
let settled = false;
const onDone = (result: boolean) => {
if (settled) return;
settled = true;
try {
socket.destroy();
} catch {
}
resolve(result);
};
socket.setTimeout(timeoutMs);
socket.once('connect', () => onDone(true));
socket.once('timeout', () => onDone(false));
socket.once('error', () => onDone(false));
socket.connect(port, host);
});
}
async function pollStatusesOnce(): Promise<void> {
const hosts = await fetchAllHosts();
if (hosts.length === 0) {
logger.warn('No hosts retrieved for status polling');
return;
}
const now = new Date().toISOString();
const checks = hosts.map(async (h) => {
const isOnline = await tcpPing(h.ip, h.port, 5000);
const now = new Date().toISOString();
const statusEntry: StatusEntry = {status: isOnline ? 'online' : 'offline', lastChecked: now};
hostStatuses.set(h.id, statusEntry);
return isOnline;
});
const results = await Promise.allSettled(checks);
const onlineCount = results.filter(r => r.status === 'fulfilled' && r.value === true).length;
const offlineCount = hosts.length - onlineCount;
}
app.get('/status', async (req, res) => {
if (hostStatuses.size === 0) {
await pollStatusesOnce();
}
const result: Record<number, StatusEntry> = {};
for (const [id, entry] of hostStatuses.entries()) {
result[id] = entry;
}
res.json(result);
});
app.get('/status/:id', async (req, res) => {
const id = Number(req.params.id);
if (!id) {
return res.status(400).json({error: 'Invalid id'});
}
try {
const host = await fetchHostById(id);
if (!host) {
return res.status(404).json({error: 'Host not found'});
}
const isOnline = await tcpPing(host.ip, host.port, 5000);
const now = new Date().toISOString();
const statusEntry: StatusEntry = {status: isOnline ? 'online' : 'offline', lastChecked: now};
hostStatuses.set(id, statusEntry);
res.json(statusEntry);
} catch (err) {
logger.error('Failed to check host status', err);
res.status(500).json({error: 'Failed to check host status'});
}
});
app.post('/refresh', async (req, res) => {
await pollStatusesOnce();
res.json({message: 'Refreshed'});
});
app.get('/metrics/:id', async (req, res) => {
const id = Number(req.params.id);
if (!id) {
return res.status(400).json({error: 'Invalid id'});
}
try {
const host = await fetchHostById(id);
if (!host) {
return res.status(404).json({error: 'Host not found'});
}
const metrics = await collectMetrics(host);
res.json({...metrics, lastChecked: new Date().toISOString()});
} catch (err) {
logger.error('Failed to collect metrics', err);
return res.json({
cpu: {percent: null, cores: null, load: null},
memory: {percent: null, usedGiB: null, totalGiB: null},
disk: {percent: null, usedHuman: null, totalHuman: null},
lastChecked: new Date().toISOString()
});
}
});
const PORT = 8085;
app.listen(PORT, async () => {
try {
await pollStatusesOnce();
} catch (err) {
logger.error('Initial poll failed', err);
}
});

View File

@@ -4,6 +4,9 @@ import chalk from 'chalk';
const wss = new WebSocketServer({port: 8082});
const sshIconSymbol = '🖥️';
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
@@ -30,16 +33,22 @@ const logger = {
}
};
wss.on('connection', (ws: WebSocket) => {
let sshConn: Client | null = null;
let sshStream: ClientChannel | null = null;
let pingInterval: NodeJS.Timeout | null = null;
ws.on('close', () => {
cleanupSSH();
});
ws.on('message', (msg: RawData) => {
let parsed: any;
try {
parsed = JSON.parse(msg.toString());
@@ -128,38 +137,17 @@ wss.on('connection', (ws: WebSocket) => {
ws.send(JSON.stringify({type: 'error', message: 'SSH connection timeout'}));
cleanupSSH(connectionTimeout);
}
}, 15000);
}, 60000);
sshConn.on('ready', () => {
clearTimeout(connectionTimeout);
const pseudoTtyOpts: PseudoTtyOptions = {
term: 'xterm-256color',
cols,
rows,
modes: {
ECHO: 1,
ECHOCTL: 0,
ICANON: 1,
ISIG: 1,
ICRNL: 1,
IXON: 1,
IXOFF: 0,
ISTRIP: 0,
OPOST: 1,
ONLCR: 1,
OCRNL: 0,
ONOCR: 0,
ONLRET: 0,
CS7: 0,
CS8: 1,
PARENB: 0,
PARODD: 0,
TTY_OP_ISPEED: 38400,
TTY_OP_OSPEED: 38400,
}
};
sshConn!.shell(pseudoTtyOpts, (err, stream) => {
sshConn!.shell({
rows: data.rows,
cols: data.cols,
term: 'xterm-256color'
} as PseudoTtyOptions, (err, stream) => {
if (err) {
logger.error('Shell error: ' + err.message);
ws.send(JSON.stringify({type: 'error', message: 'Shell error: ' + err.message}));
@@ -168,34 +156,18 @@ wss.on('connection', (ws: WebSocket) => {
sshStream = stream;
stream.on('data', (chunk: Buffer) => {
let data: string;
try {
data = chunk.toString('utf8');
} catch (e) {
data = chunk.toString('binary');
}
ws.send(JSON.stringify({type: 'data', data}));
stream.on('data', (data: Buffer) => {
ws.send(JSON.stringify({type: 'data', data: data.toString()}));
});
stream.on('close', () => {
cleanupSSH(connectionTimeout);
ws.send(JSON.stringify({type: 'disconnected', message: 'Connection lost'}));
});
stream.on('error', (err: Error) => {
logger.error('SSH stream error: ' + err.message);
const isConnectionError = err.message.includes('ECONNRESET') ||
err.message.includes('EPIPE') ||
err.message.includes('ENOTCONN') ||
err.message.includes('ETIMEDOUT');
if (isConnectionError) {
ws.send(JSON.stringify({type: 'disconnected', message: 'Connection lost'}));
} else {
ws.send(JSON.stringify({type: 'error', message: 'SSH stream error: ' + err.message}));
}
ws.send(JSON.stringify({type: 'error', message: 'SSH stream error: ' + err.message}));
});
setupPingInterval();
@@ -233,18 +205,22 @@ wss.on('connection', (ws: WebSocket) => {
sshConn.on('close', () => {
clearTimeout(connectionTimeout);
cleanupSSH(connectionTimeout);
});
const connectConfig: any = {
host: ip,
port,
username,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
readyTimeout: 10000,
readyTimeout: 60000,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
env: {
TERM: 'xterm-256color',
LANG: 'en_US.UTF-8',
@@ -294,12 +270,26 @@ wss.on('connection', (ws: WebSocket) => {
}
};
if (authType === 'key' && key) {
connectConfig.privateKey = key;
if (keyPassword) {
connectConfig.passphrase = keyPassword;
}
if (keyType && keyType !== 'auto') {
connectConfig.privateKeyType = keyType;
try {
if (!key.includes('-----BEGIN') || !key.includes('-----END')) {
throw new Error('Invalid private key format');
}
const cleanKey = key.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
connectConfig.privateKey = Buffer.from(cleanKey, 'utf8');
if (keyPassword) {
connectConfig.passphrase = keyPassword;
}
if (keyType && keyType !== 'auto') {
connectConfig.privateKeyType = keyType;
}
} catch (keyError) {
logger.error('SSH key format error: ' + keyError.message);
ws.send(JSON.stringify({type: 'error', message: 'SSH key format error: Invalid private key format'}));
return;
}
} else if (authType === 'key') {
logger.error('SSH key authentication requested but no key provided');
@@ -360,4 +350,6 @@ wss.on('connection', (ws: WebSocket) => {
}
}, 60000);
}
});

View File

@@ -78,7 +78,7 @@ interface SSHHost {
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableConfigEditor: boolean;
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: TunnelConnection[];
createdAt: string;
@@ -197,7 +197,8 @@ function classifyError(errorMessage: string): ErrorType {
if (message.includes("connect etimedout") ||
message.includes("timeout") ||
message.includes("timed out")) {
message.includes("timed out") ||
message.includes("keepalive timeout")) {
return ERROR_TYPES.TIMEOUT;
}
@@ -267,7 +268,8 @@ function cleanupTunnelResources(tunnelName: string): void {
tunnelName,
`${tunnelName}_confirm`,
`${tunnelName}_retry`,
`${tunnelName}_verify_retry`
`${tunnelName}_verify_retry`,
`${tunnelName}_ping`
];
timerKeys.forEach(key => {
@@ -302,7 +304,7 @@ function resetRetryState(tunnelName: string): void {
countdownIntervals.delete(tunnelName);
}
['', '_confirm', '_retry', '_verify_retry'].forEach(suffix => {
['', '_confirm', '_retry', '_verify_retry', '_ping'].forEach(suffix => {
const timerKey = `${tunnelName}${suffix}`;
if (verificationTimers.has(timerKey)) {
clearTimeout(verificationTimers.get(timerKey)!);
@@ -353,7 +355,8 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
const maxRetries = tunnelConfig.maxRetries || 3;
const retryInterval = tunnelConfig.retryInterval || 5000;
let retryCount = (retryCounters.get(tunnelName) || 0) + 1;
let retryCount = retryCounters.get(tunnelName) || 0;
retryCount = retryCount + 1;
if (retryCount > maxRetries) {
logger.error(`All ${maxRetries} retries failed for ${tunnelName}`);
@@ -420,7 +423,6 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
if (!manualDisconnects.has(tunnelName)) {
activeTunnels.delete(tunnelName);
connectSSHTunnel(tunnelConfig, retryCount);
}
}, retryInterval);
@@ -438,264 +440,43 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
}
function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig, isPeriodic = false): void {
if (manualDisconnects.has(tunnelName) || !activeTunnels.has(tunnelName)) {
return;
}
if (tunnelVerifications.has(tunnelName)) {
return;
}
const conn = activeTunnels.get(tunnelName);
if (!conn) return;
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.VERIFYING
});
const verificationConn = new Client();
tunnelVerifications.set(tunnelName, {
conn: verificationConn,
timeout: setTimeout(() => {
logger.error(`Verification timeout for '${tunnelName}'`);
cleanupVerification(false, "Verification timeout");
}, 10000)
});
function cleanupVerification(isSuccessful: boolean, failureReason = "Unknown verification failure") {
const verification = tunnelVerifications.get(tunnelName);
if (verification) {
clearTimeout(verification.timeout);
try {
verification.conn.end();
} catch (e) {
}
tunnelVerifications.delete(tunnelName);
}
if (isSuccessful) {
if (isPeriodic) {
if (!activeTunnels.has(tunnelName)) {
broadcastTunnelStatus(tunnelName, {
connected: true,
status: CONNECTION_STATES.CONNECTED
connected: false,
status: CONNECTION_STATES.DISCONNECTED,
reason: 'Tunnel connection lost'
});
if (!isPeriodic) {
setupPingInterval(tunnelName, tunnelConfig);
}
} else {
logger.warn(`Verification failed for '${tunnelName}': ${failureReason}`);
if (failureReason.includes('command failed') || failureReason.includes('connection error') || failureReason.includes('timeout')) {
if (!manualDisconnects.has(tunnelName)) {
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.FAILED,
reason: failureReason
});
}
activeTunnels.delete(tunnelName);
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
} else {
logger.info(`Assuming tunnel '${tunnelName}' is working despite verification warning: ${failureReason}`);
cleanupVerification(true);
}
}
}
function attemptVerification() {
const testCmd = `timeout 3 bash -c 'nc -z ${tunnelConfig.endpointIP} ${tunnelConfig.endpointPort}'`;
verificationConn.exec(testCmd, (err, stream) => {
if (err) {
logger.error(`Verification command failed for '${tunnelName}': ${err.message}`);
cleanupVerification(false, `Verification command failed: ${err.message}`);
return;
}
let output = '';
let errorOutput = '';
stream.on('data', (data: Buffer) => {
output += data.toString();
});
stream.stderr?.on('data', (data: Buffer) => {
errorOutput += data.toString();
});
stream.on('close', (code: number) => {
if (code === 0) {
cleanupVerification(true);
} else {
const isTimeout = errorOutput.includes('timeout') || errorOutput.includes('Connection timed out');
const isConnectionRefused = errorOutput.includes('Connection refused') || errorOutput.includes('No route to host');
let failureReason = `Cannot connect to ${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort}`;
if (isTimeout) {
failureReason = `Tunnel verification timeout - cannot reach ${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort}`;
} else if (isConnectionRefused) {
failureReason = `Connection refused to ${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort} - tunnel may not be established`;
}
cleanupVerification(false, failureReason);
}
});
stream.on('error', (err: Error) => {
logger.error(`Verification stream error for '${tunnelName}': ${err.message}`);
cleanupVerification(false, `Verification stream error: ${err.message}`);
});
});
}
verificationConn.on('ready', () => {
setTimeout(() => {
attemptVerification();
}, 2000);
});
verificationConn.on('error', (err: Error) => {
cleanupVerification(false, `Verification connection error: ${err.message}`);
});
verificationConn.on('close', () => {
if (tunnelVerifications.has(tunnelName)) {
cleanupVerification(false, "Verification connection closed");
}
});
const connOptions: any = {
host: tunnelConfig.sourceIP,
port: tunnelConfig.sourceSSHPort,
username: tunnelConfig.sourceUsername,
readyTimeout: 10000,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
algorithms: {
kex: [
'diffie-hellman-group14-sha256',
'diffie-hellman-group14-sha1',
'diffie-hellman-group1-sha1',
'diffie-hellman-group-exchange-sha256',
'diffie-hellman-group-exchange-sha1',
'ecdh-sha2-nistp256',
'ecdh-sha2-nistp384',
'ecdh-sha2-nistp521'
],
cipher: [
'aes128-ctr',
'aes192-ctr',
'aes256-ctr',
'aes128-gcm@openssh.com',
'aes256-gcm@openssh.com',
'aes128-cbc',
'aes192-cbc',
'aes256-cbc',
'3des-cbc'
],
hmac: [
'hmac-sha2-256',
'hmac-sha2-512',
'hmac-sha1',
'hmac-md5'
],
compress: [
'none',
'zlib@openssh.com',
'zlib'
]
}
};
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
connOptions.privateKey = tunnelConfig.sourceSSHKey;
if (tunnelConfig.sourceKeyPassword) {
connOptions.passphrase = tunnelConfig.sourceKeyPassword;
}
if (tunnelConfig.sourceKeyType && tunnelConfig.sourceKeyType !== 'auto') {
connOptions.privateKeyType = tunnelConfig.sourceKeyType;
}
} else if (tunnelConfig.sourceAuthMethod === "key") {
logger.error(`SSH key authentication requested but no key provided for tunnel '${tunnelName}'`);
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.FAILED,
reason: "SSH key authentication requested but no key provided"
});
return;
} else {
connOptions.password = tunnelConfig.sourcePassword;
}
verificationConn.connect(connOptions);
}
function setupPingInterval(tunnelName: string, tunnelConfig: TunnelConfig): void {
const pingKey = `${tunnelName}_ping`;
if (verificationTimers.has(pingKey)) {
clearInterval(verificationTimers.get(pingKey)!);
verificationTimers.delete(pingKey);
}
const pingInterval = setInterval(() => {
if (!activeTunnels.has(tunnelName) || manualDisconnects.has(tunnelName)) {
clearInterval(pingInterval);
return;
}
const conn = activeTunnels.get(tunnelName);
if (!conn) {
clearInterval(pingInterval);
return;
}
conn.exec('echo "ping"', (err, stream) => {
if (err) {
const currentStatus = connectionStatus.get(tunnelName);
if (currentStatus?.status === CONNECTION_STATES.CONNECTED) {
if (!activeTunnels.has(tunnelName)) {
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.DISCONNECTED,
reason: 'Tunnel connection lost'
});
clearInterval(pingInterval);
if (!manualDisconnects.has(tunnelName)) {
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.UNSTABLE,
reason: "Ping failed"
});
}
activeTunnels.delete(tunnelName);
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
return;
verificationTimers.delete(pingKey);
}
stream.on('close', (code: number) => {
if (code !== 0) {
clearInterval(pingInterval);
if (!manualDisconnects.has(tunnelName)) {
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.UNSTABLE,
reason: "Ping command failed"
});
}
activeTunnels.delete(tunnelName);
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
}
});
stream.on('error', (err: Error) => {
clearInterval(pingInterval);
if (!manualDisconnects.has(tunnelName)) {
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.UNSTABLE,
reason: "Ping stream error"
});
}
activeTunnels.delete(tunnelName);
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
});
});
}, 60000);
} else {
clearInterval(pingInterval);
verificationTimers.delete(pingKey);
}
}, 120000);
verificationTimers.set(pingKey, pingInterval);
}
function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
@@ -751,7 +532,7 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
}
}
}, 15000);
}, 60000);
conn.on("error", (err) => {
clearTimeout(connectionTimeout);
@@ -778,6 +559,8 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
errorType === ERROR_TYPES.PORT ||
errorType === ERROR_TYPES.PERMISSION ||
manualDisconnects.has(tunnelName);
handleDisconnect(tunnelName, tunnelConfig, !shouldNotRetry);
});
@@ -841,7 +624,11 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
setTimeout(() => {
if (!manualDisconnects.has(tunnelName) && activeTunnels.has(tunnelName)) {
verifyTunnelConnection(tunnelName, tunnelConfig, false);
broadcastTunnelStatus(tunnelName, {
connected: true,
status: CONNECTION_STATES.CONNECTED
});
setupPingInterval(tunnelName, tunnelConfig);
}
}, 2000);
@@ -901,7 +688,6 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
stream.stderr.on("data", (data) => {
const errorMsg = data.toString().trim();
logger.debug(`Tunnel stderr for '${tunnelName}': ${errorMsg}`);
});
});
});
@@ -910,11 +696,11 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
host: tunnelConfig.sourceIP,
port: tunnelConfig.sourceSSHPort,
username: tunnelConfig.sourceUsername,
keepaliveInterval: 30000,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
readyTimeout: 10000,
readyTimeout: 60000,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
tcpKeepAliveInitialDelay: 15000,
algorithms: {
kex: [
'diffie-hellman-group14-sha256',
@@ -952,8 +738,8 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
};
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
if (!tunnelConfig.sourceSSHKey.includes('-----BEGIN')) {
logger.error(`Invalid SSH key format for tunnel '${tunnelName}'. Key should start with '-----BEGIN'`);
if (!tunnelConfig.sourceSSHKey.includes('-----BEGIN') || !tunnelConfig.sourceSSHKey.includes('-----END')) {
logger.error(`Invalid SSH key format for tunnel '${tunnelName}'. Key should contain both BEGIN and END markers`);
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.FAILED,
@@ -962,7 +748,8 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
return;
}
connOptions.privateKey = tunnelConfig.sourceSSHKey;
const cleanKey = tunnelConfig.sourceSSHKey.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
connOptions.privateKey = Buffer.from(cleanKey, 'utf8');
if (tunnelConfig.sourceKeyPassword) {
connOptions.passphrase = tunnelConfig.sourceKeyPassword;
}
@@ -981,43 +768,16 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
connOptions.password = tunnelConfig.sourcePassword;
}
const testSocket = new net.Socket();
testSocket.setTimeout(5000);
testSocket.on('connect', () => {
testSocket.destroy();
const currentStatus = connectionStatus.get(tunnelName);
if (!currentStatus || currentStatus.status !== CONNECTION_STATES.WAITING) {
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.CONNECTING,
retryCount: retryAttempt > 0 ? retryAttempt : undefined
});
}
conn.connect(connOptions);
});
testSocket.on('timeout', () => {
testSocket.destroy();
const finalStatus = connectionStatus.get(tunnelName);
if (!finalStatus || finalStatus.status !== CONNECTION_STATES.WAITING) {
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.FAILED,
reason: "Network connectivity test failed - server not reachable"
status: CONNECTION_STATES.CONNECTING,
retryCount: retryAttempt > 0 ? retryAttempt : undefined
});
});
}
testSocket.on('error', (err: any) => {
testSocket.destroy();
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.FAILED,
reason: `Network connectivity test failed - ${err.message}`
});
});
testSocket.connect(tunnelConfig.sourceSSHPort, tunnelConfig.sourceIP);
conn.connect(connOptions);
}
function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string, callback: (err?: Error) => void) {
@@ -1029,9 +789,9 @@ function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string
username: tunnelConfig.sourceUsername,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
readyTimeout: 10000,
readyTimeout: 60000,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
tcpKeepAliveInitialDelay: 15000,
algorithms: {
kex: [
'diffie-hellman-group14-sha256',
@@ -1068,7 +828,13 @@ function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string
}
};
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
connOptions.privateKey = tunnelConfig.sourceSSHKey;
if (!tunnelConfig.sourceSSHKey.includes('-----BEGIN') || !tunnelConfig.sourceSSHKey.includes('-----END')) {
callback(new Error('Invalid SSH key format'));
return;
}
const cleanKey = tunnelConfig.sourceSSHKey.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
connOptions.privateKey = Buffer.from(cleanKey, 'utf8');
if (tunnelConfig.sourceKeyPassword) {
connOptions.passphrase = tunnelConfig.sourceKeyPassword;
}

View File

@@ -2,9 +2,10 @@
// node ./dist/backend/starter.js
import './database/database.js'
import './ssh/ssh.js';
import './ssh_tunnel/ssh_tunnel.js';
import './config_editor/config_editor.js';
import './ssh/terminal.js';
import './ssh/tunnel.js';
import './ssh/file-manager.js';
import './ssh/server-stats.js';
import chalk from 'chalk';
const fixedIconSymbol = '🚀';

View File

@@ -0,0 +1,58 @@
import { Children, ReactElement, cloneElement, isValidElement } from 'react';
import { ButtonProps } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface ButtonGroupProps {
className?: string;
orientation?: 'horizontal' | 'vertical';
children: ReactElement<ButtonProps>[] | React.ReactNode;
}
export const ButtonGroup = ({
className,
orientation = 'horizontal',
children,
}: ButtonGroupProps) => {
const isHorizontal = orientation === 'horizontal';
const isVertical = orientation === 'vertical';
// Normalize and filter only valid React elements
const childArray = Children.toArray(children).filter((child): child is ReactElement<ButtonProps> =>
isValidElement(child)
);
const totalButtons = childArray.length;
return (
<div
className={cn(
'flex',
{
'flex-col': isVertical,
'w-fit': isVertical,
},
className
)}
>
{childArray.map((child, index) => {
const isFirst = index === 0;
const isLast = index === totalButtons - 1;
return cloneElement(child, {
className: cn(
{
'rounded-l-none': isHorizontal && !isFirst,
'rounded-r-none': isHorizontal && !isLast,
'border-l-0': isHorizontal && !isFirst,
'rounded-t-none': isVertical && !isFirst,
'rounded-b-none': isVertical && !isLast,
'border-t-0': isVertical && !isFirst,
},
child.props.className
),
});
})}
</div>
);
};

View File

@@ -0,0 +1,29 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }

View File

@@ -0,0 +1,62 @@
import type { ComponentProps, HTMLAttributes } from 'react';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
export type StatusProps = ComponentProps<typeof Badge> & {
status: 'online' | 'offline' | 'maintenance' | 'degraded';
};
export const Status = ({ className, status, ...props }: StatusProps) => (
<Badge
className={cn('flex items-center gap-2', 'group', status, className)}
variant="secondary"
{...props}
/>
);
export type StatusIndicatorProps = HTMLAttributes<HTMLSpanElement>;
export const StatusIndicator = ({
className,
...props
}: StatusIndicatorProps) => (
<span className="relative flex h-2 w-2" {...props}>
<span
className={cn(
'absolute inline-flex h-full w-full animate-ping rounded-full opacity-75',
'group-[.online]:bg-emerald-500',
'group-[.offline]:bg-red-500',
'group-[.maintenance]:bg-blue-500',
'group-[.degraded]:bg-amber-500'
)}
/>
<span
className={cn(
'relative inline-flex h-2 w-2 rounded-full',
'group-[.online]:bg-emerald-500',
'group-[.offline]:bg-red-500',
'group-[.maintenance]:bg-blue-500',
'group-[.degraded]:bg-amber-500'
)}
/>
</span>
);
export type StatusLabelProps = HTMLAttributes<HTMLSpanElement>;
export const StatusLabel = ({
className,
children,
...props
}: StatusLabelProps) => (
<span className={cn('text-muted-foreground', className)} {...props}>
{children ?? (
<>
<span className="hidden group-[.online]:block">Online</span>
<span className="hidden group-[.offline]:block">Offline</span>
<span className="hidden group-[.maintenance]:block">Maintenance</span>
<span className="hidden group-[.degraded]:block">Degraded</span>
</>
)}
</span>
);

View File

@@ -34,7 +34,7 @@ function SheetOverlay({
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:pointer-events-none",
className
)}
{...props}
@@ -56,7 +56,7 @@ function SheetContent({
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=closed]:pointer-events-none",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&

View File

@@ -1,6 +1,6 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cva, VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
@@ -137,7 +137,7 @@ function SidebarProvider({
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex h-full w-full",
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
)}
{...props}
@@ -242,7 +242,7 @@ function Sidebar({
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border-2 group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
@@ -720,6 +720,5 @@ export {
SidebarRail,
SidebarSeparator,
SidebarTrigger,
// eslint-disable-next-line react-refresh/only-export-components
useSidebar,
}

View File

@@ -0,0 +1,23 @@
import { useTheme } from "next-themes"
import { Toaster as Sonner, ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

114
src/components/ui/table.tsx Normal file
View File

@@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -52,7 +52,6 @@ function TooltipContent({
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)

View File

@@ -130,4 +130,48 @@
body {
@apply bg-background text-foreground;
}
}
.thin-scrollbar {
scrollbar-width: thin;
scrollbar-color: #303032 transparent;
}
.thin-scrollbar::-webkit-scrollbar {
height: 6px;
width: 6px;
}
.thin-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.thin-scrollbar::-webkit-scrollbar-thumb {
background-color: #303032;
border-radius: 9999px;
border: 2px solid transparent;
background-clip: content-box;
}
.thin-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.thin-scrollbar::-webkit-scrollbar-track {
background: #18181b;
}
.thin-scrollbar::-webkit-scrollbar-thumb {
background: #434345;
border-radius: 3px;
}
.thin-scrollbar::-webkit-scrollbar-thumb:hover {
background: #5a5a5d;
}
.thin-scrollbar {
scrollbar-width: thin;
scrollbar-color: #434345 #18181b;
}

View File

@@ -0,0 +1,450 @@
import React from "react";
import {useSidebar} from "@/components/ui/sidebar";
import {Separator} from "@/components/ui/separator.tsx";
import {Button} from "@/components/ui/button.tsx";
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
import {Checkbox} from "@/components/ui/checkbox.tsx";
import {Input} from "@/components/ui/input.tsx";
import {Label} from "@/components/ui/label.tsx";
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table.tsx";
import {Shield, Trash2, Users} from "lucide-react";
import {
getOIDCConfig,
getRegistrationAllowed,
getUserList,
updateRegistrationAllowed,
updateOIDCConfig,
makeUserAdmin,
removeAdminStatus,
deleteUser
} from "@/ui/main-axios.ts";
function getCookie(name: string) {
return document.cookie.split('; ').reduce((r, v) => {
const parts = v.split('=');
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
}, "");
}
interface AdminSettingsProps {
isTopbarOpen?: boolean;
}
export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.ReactElement {
const {state: sidebarState} = useSidebar();
const [allowRegistration, setAllowRegistration] = React.useState(true);
const [regLoading, setRegLoading] = React.useState(false);
const [oidcConfig, setOidcConfig] = React.useState({
client_id: '',
client_secret: '',
issuer_url: '',
authorization_url: '',
token_url: '',
identifier_path: 'sub',
name_path: 'name',
scopes: 'openid email profile'
});
const [oidcLoading, setOidcLoading] = React.useState(false);
const [oidcError, setOidcError] = React.useState<string | null>(null);
const [oidcSuccess, setOidcSuccess] = React.useState<string | null>(null);
const [users, setUsers] = React.useState<Array<{
id: string;
username: string;
is_admin: boolean;
is_oidc: boolean
}>>([]);
const [usersLoading, setUsersLoading] = React.useState(false);
const [newAdminUsername, setNewAdminUsername] = React.useState("");
const [makeAdminLoading, setMakeAdminLoading] = React.useState(false);
const [makeAdminError, setMakeAdminError] = React.useState<string | null>(null);
const [makeAdminSuccess, setMakeAdminSuccess] = React.useState<string | null>(null);
React.useEffect(() => {
const jwt = getCookie("jwt");
if (!jwt) return;
getOIDCConfig()
.then(res => {
if (res) setOidcConfig(res);
})
.catch(() => {
});
fetchUsers();
}, []);
React.useEffect(() => {
getRegistrationAllowed()
.then(res => {
if (typeof res?.allowed === 'boolean') {
setAllowRegistration(res.allowed);
}
})
.catch(() => {
});
}, []);
const fetchUsers = async () => {
const jwt = getCookie("jwt");
if (!jwt) return;
setUsersLoading(true);
try {
const response = await getUserList();
setUsers(response.users);
} finally {
setUsersLoading(false);
}
};
const handleToggleRegistration = async (checked: boolean) => {
setRegLoading(true);
const jwt = getCookie("jwt");
try {
await updateRegistrationAllowed(checked);
setAllowRegistration(checked);
} finally {
setRegLoading(false);
}
};
const handleOIDCConfigSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setOidcLoading(true);
setOidcError(null);
setOidcSuccess(null);
const required = ['client_id', 'client_secret', 'issuer_url', 'authorization_url', 'token_url'];
const missing = required.filter(f => !oidcConfig[f as keyof typeof oidcConfig]);
if (missing.length > 0) {
setOidcError(`Missing required fields: ${missing.join(', ')}`);
setOidcLoading(false);
return;
}
const jwt = getCookie("jwt");
try {
await updateOIDCConfig(oidcConfig);
setOidcSuccess("OIDC configuration updated successfully!");
} catch (err: any) {
setOidcError(err?.response?.data?.error || "Failed to update OIDC configuration");
} finally {
setOidcLoading(false);
}
};
const handleOIDCConfigChange = (field: string, value: string) => {
setOidcConfig(prev => ({...prev, [field]: value}));
};
const makeUserAdmin = async (e: React.FormEvent) => {
e.preventDefault();
if (!newAdminUsername.trim()) return;
setMakeAdminLoading(true);
setMakeAdminError(null);
setMakeAdminSuccess(null);
const jwt = getCookie("jwt");
try {
await makeUserAdmin(newAdminUsername.trim());
setMakeAdminSuccess(`User ${newAdminUsername} is now an admin`);
setNewAdminUsername("");
fetchUsers();
} catch (err: any) {
setMakeAdminError(err?.response?.data?.error || "Failed to make user admin");
} finally {
setMakeAdminLoading(false);
}
};
const removeAdminStatus = async (username: string) => {
if (!confirm(`Remove admin status from ${username}?`)) return;
const jwt = getCookie("jwt");
try {
await removeAdminStatus(username);
fetchUsers();
} catch {
}
};
const deleteUser = async (username: string) => {
if (!confirm(`Delete user ${username}? This cannot be undone.`)) return;
const jwt = getCookie("jwt");
try {
await deleteUser(username);
fetchUsers();
} catch {
}
};
const topMarginPx = isTopbarOpen ? 74 : 26;
const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8;
const bottomMarginPx = 8;
const wrapperStyle: React.CSSProperties = {
marginLeft: leftMarginPx,
marginRight: 17,
marginTop: topMarginPx,
marginBottom: bottomMarginPx,
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`
};
return (
<div style={wrapperStyle}
className="bg-[#18181b] text-white rounded-lg border-2 border-[#303032] overflow-hidden">
<div className="h-full w-full flex flex-col">
<div className="flex items-center justify-between px-3 pt-2 pb-2">
<h1 className="font-bold text-lg">Admin Settings</h1>
</div>
<Separator className="p-0.25 w-full"/>
<div className="px-6 py-4 overflow-auto">
<Tabs defaultValue="registration" className="w-full">
<TabsList className="mb-4 bg-[#18181b] border-2 border-[#303032]">
<TabsTrigger value="registration" className="flex items-center gap-2">
<Users className="h-4 w-4"/>
General
</TabsTrigger>
<TabsTrigger value="oidc" className="flex items-center gap-2">
<Shield className="h-4 w-4"/>
OIDC
</TabsTrigger>
<TabsTrigger value="users" className="flex items-center gap-2">
<Users className="h-4 w-4"/>
Users
</TabsTrigger>
<TabsTrigger value="admins" className="flex items-center gap-2">
<Shield className="h-4 w-4"/>
Admins
</TabsTrigger>
</TabsList>
<TabsContent value="registration" className="space-y-6">
<div className="space-y-4">
<h3 className="text-lg font-semibold">User Registration</h3>
<label className="flex items-center gap-2">
<Checkbox checked={allowRegistration} onCheckedChange={handleToggleRegistration}
disabled={regLoading}/>
Allow new account registration
</label>
</div>
</TabsContent>
<TabsContent value="oidc" className="space-y-6">
<div className="space-y-4">
<h3 className="text-lg font-semibold">External Authentication (OIDC)</h3>
<p className="text-sm text-muted-foreground">Configure external identity provider for
OIDC/OAuth2 authentication.</p>
{oidcError && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{oidcError}</AlertDescription>
</Alert>
)}
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="client_id">Client ID</Label>
<Input id="client_id" value={oidcConfig.client_id}
onChange={(e) => handleOIDCConfigChange('client_id', e.target.value)}
placeholder="your-client-id" required/>
</div>
<div className="space-y-2">
<Label htmlFor="client_secret">Client Secret</Label>
<Input id="client_secret" type="password" value={oidcConfig.client_secret}
onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)}
placeholder="your-client-secret" required/>
</div>
<div className="space-y-2">
<Label htmlFor="authorization_url">Authorization URL</Label>
<Input id="authorization_url" value={oidcConfig.authorization_url}
onChange={(e) => handleOIDCConfigChange('authorization_url', e.target.value)}
placeholder="https://your-provider.com/application/o/authorize/"
required/>
</div>
<div className="space-y-2">
<Label htmlFor="issuer_url">Issuer URL</Label>
<Input id="issuer_url" value={oidcConfig.issuer_url}
onChange={(e) => handleOIDCConfigChange('issuer_url', e.target.value)}
placeholder="https://your-provider.com/application/o/termix/" required/>
</div>
<div className="space-y-2">
<Label htmlFor="token_url">Token URL</Label>
<Input id="token_url" value={oidcConfig.token_url}
onChange={(e) => handleOIDCConfigChange('token_url', e.target.value)}
placeholder="https://your-provider.com/application/o/token/" required/>
</div>
<div className="space-y-2">
<Label htmlFor="identifier_path">User Identifier Path</Label>
<Input id="identifier_path" value={oidcConfig.identifier_path}
onChange={(e) => handleOIDCConfigChange('identifier_path', e.target.value)}
placeholder="sub" required/>
</div>
<div className="space-y-2">
<Label htmlFor="name_path">Display Name Path</Label>
<Input id="name_path" value={oidcConfig.name_path}
onChange={(e) => handleOIDCConfigChange('name_path', e.target.value)}
placeholder="name" required/>
</div>
<div className="space-y-2">
<Label htmlFor="scopes">Scopes</Label>
<Input id="scopes" value={oidcConfig.scopes}
onChange={(e) => handleOIDCConfigChange('scopes', (e.target as HTMLInputElement).value)}
placeholder="openid email profile" required/>
</div>
<div className="flex gap-2 pt-2">
<Button type="submit" className="flex-1"
disabled={oidcLoading}>{oidcLoading ? "Saving..." : "Save Configuration"}</Button>
<Button type="button" variant="outline" onClick={() => setOidcConfig({
client_id: '',
client_secret: '',
issuer_url: '',
authorization_url: '',
token_url: '',
identifier_path: 'sub',
name_path: 'name',
scopes: 'openid email profile'
})}>Reset</Button>
</div>
{oidcSuccess && (
<Alert>
<AlertTitle>Success</AlertTitle>
<AlertDescription>{oidcSuccess}</AlertDescription>
</Alert>
)}
</form>
</div>
</TabsContent>
<TabsContent value="users" className="space-y-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">User Management</h3>
<Button onClick={fetchUsers} disabled={usersLoading} variant="outline"
size="sm">{usersLoading ? "Loading..." : "Refresh"}</Button>
</div>
{usersLoading ? (
<div className="text-center py-8 text-muted-foreground">Loading users...</div>
) : (
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="px-4">Username</TableHead>
<TableHead className="px-4">Type</TableHead>
<TableHead className="px-4">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell className="px-4 font-medium">
{user.username}
{user.is_admin && (
<span
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">Admin</span>
)}
</TableCell>
<TableCell
className="px-4">{user.is_oidc ? "External" : "Local"}</TableCell>
<TableCell className="px-4">
<Button variant="ghost" size="sm"
onClick={() => deleteUser(user.username)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
disabled={user.is_admin}>
<Trash2 className="h-4 w-4"/>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</TabsContent>
<TabsContent value="admins" className="space-y-6">
<div className="space-y-6">
<h3 className="text-lg font-semibold">Admin Management</h3>
<div className="space-y-4 p-6 border rounded-md bg-muted/50">
<h4 className="font-medium">Make User Admin</h4>
<form onSubmit={makeUserAdmin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="new-admin-username">Username</Label>
<div className="flex gap-2">
<Input id="new-admin-username" value={newAdminUsername}
onChange={(e) => setNewAdminUsername(e.target.value)}
placeholder="Enter username to make admin" required/>
<Button type="submit"
disabled={makeAdminLoading || !newAdminUsername.trim()}>{makeAdminLoading ? "Adding..." : "Make Admin"}</Button>
</div>
</div>
{makeAdminError && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{makeAdminError}</AlertDescription>
</Alert>
)}
{makeAdminSuccess && (
<Alert>
<AlertTitle>Success</AlertTitle>
<AlertDescription>{makeAdminSuccess}</AlertDescription>
</Alert>
)}
</form>
</div>
<div className="space-y-4">
<h4 className="font-medium">Current Admins</h4>
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="px-4">Username</TableHead>
<TableHead className="px-4">Type</TableHead>
<TableHead className="px-4">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.filter(u => u.is_admin).map((admin) => (
<TableRow key={admin.id}>
<TableCell className="px-4 font-medium">
{admin.username}
<span
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">Admin</span>
</TableCell>
<TableCell
className="px-4">{admin.is_oidc ? "External" : "Local"}</TableCell>
<TableCell className="px-4">
<Button variant="ghost" size="sm"
onClick={() => removeAdminStatus(admin.username)}
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50">
<Shield className="h-4 w-4"/>
Remove Admin
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</div>
</TabsContent>
</Tabs>
</div>
</div>
</div>
);
}
export default AdminSettings;

View File

@@ -0,0 +1,24 @@
import React from "react";
import { FileManagerTabList } from "./FileManagerTabList.tsx";
interface FileManagerTopNavbarProps {
tabs: {id: string | number, title: string}[];
activeTab: string | number;
setActiveTab: (tab: string | number) => void;
closeTab: (tab: string | number) => void;
onHomeClick: () => void;
}
export function FIleManagerTopNavbar(props: FileManagerTopNavbarProps): React.ReactElement {
const { tabs, activeTab, setActiveTab, closeTab, onHomeClick } = props;
return (
<FileManagerTabList
tabs={tabs}
activeTab={activeTab}
setActiveTab={setActiveTab}
closeTab={closeTab}
onHomeClick={onHomeClick}
/>
);
}

View File

@@ -1,26 +1,30 @@
import React, {useState, useEffect, useRef} from "react";
import {ConfigEditorSidebar} from "@/apps/SSH/Config Editor/ConfigEditorSidebar.tsx";
import {ConfigTabList} from "@/apps/SSH/Config Editor/ConfigTabList.tsx";
import {ConfigHomeView} from "@/apps/SSH/Config Editor/ConfigHomeView.tsx";
import {ConfigCodeEditor} from "@/apps/SSH/Config Editor/ConfigCodeEditor.tsx";
import {FileManagerLeftSidebar} from "@/ui/Apps/File Manager/FileManagerLeftSidebar.tsx";
import {FileManagerTabList} from "@/ui/Apps/File Manager/FileManagerTabList.tsx";
import {FileManagerHomeView} from "@/ui/Apps/File Manager/FileManagerHomeView.tsx";
import {FileManagerFileEditor} from "@/ui/Apps/File Manager/FileManagerFileEditor.tsx";
import {FileManagerOperations} from "@/ui/Apps/File Manager/FileManagerOperations.tsx";
import {Button} from '@/components/ui/button.tsx';
import {ConfigTopbar} from "@/apps/SSH/Config Editor/ConfigTopbar.tsx";
import {FIleManagerTopNavbar} from "@/ui/Apps/File Manager/FIleManagerTopNavbar.tsx";
import {cn} from '@/lib/utils.ts';
import {Save, RefreshCw, Settings, Trash2} from 'lucide-react';
import {Separator} from '@/components/ui/separator.tsx';
import {toast} from 'sonner';
import {
getConfigEditorRecent,
getConfigEditorPinned,
getConfigEditorShortcuts,
addConfigEditorRecent,
removeConfigEditorRecent,
addConfigEditorPinned,
removeConfigEditorPinned,
addConfigEditorShortcut,
removeConfigEditorShortcut,
getFileManagerRecent,
getFileManagerPinned,
getFileManagerShortcuts,
addFileManagerRecent,
removeFileManagerRecent,
addFileManagerPinned,
removeFileManagerPinned,
addFileManagerShortcut,
removeFileManagerShortcut,
readSSHFile,
writeSSHFile,
getSSHStatus,
connectSSH
} from '@/apps/SSH/ssh-axios.ts';
} from '@/ui/main-axios.ts';
interface Tab {
id: string | number;
@@ -31,8 +35,6 @@ interface Tab {
sshSessionId?: string;
filePath?: string;
loading?: boolean;
error?: string;
success?: string;
dirty?: boolean;
}
@@ -52,14 +54,18 @@ interface SSHHost {
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableConfigEditor: boolean;
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
updatedAt: string;
}
export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => void }): React.ReactElement {
export function FileManager({onSelectView, embedded = false, initialHost = null}: {
onSelectView?: (view: string) => void,
embedded?: boolean,
initialHost?: SSHHost | null
}): React.ReactElement {
const [tabs, setTabs] = useState<Tab[]>([]);
const [activeTab, setActiveTab] = useState<string | number>('home');
const [recent, setRecent] = useState<any[]>([]);
@@ -69,8 +75,28 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
const [currentHost, setCurrentHost] = useState<SSHHost | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [showOperations, setShowOperations] = useState(false);
const [currentPath, setCurrentPath] = useState('/');
const [deletingItem, setDeletingItem] = useState<any | null>(null);
const sidebarRef = useRef<any>(null);
useEffect(() => {
if (initialHost && (!currentHost || currentHost.id !== initialHost.id)) {
setCurrentHost(initialHost);
setTimeout(() => {
try {
const path = initialHost.defaultPath || '/';
if (sidebarRef.current && sidebarRef.current.openFolder) {
sidebarRef.current.openFolder(initialHost, path);
}
} catch (e) {
}
}, 0);
}
}, [initialHost]);
useEffect(() => {
if (currentHost) {
fetchHomeData();
@@ -102,16 +128,16 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
try {
const homeDataPromise = Promise.all([
getConfigEditorRecent(currentHost.id),
getConfigEditorPinned(currentHost.id),
getConfigEditorShortcuts(currentHost.id),
getFileManagerRecent(currentHost.id),
getFileManagerPinned(currentHost.id),
getFileManagerShortcuts(currentHost.id),
]);
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Fetch home data timed out')), 15000)
);
const [recentRes, pinnedRes, shortcutsRes] = await Promise.race([homeDataPromise, timeoutPromise]);
const [recentRes, pinnedRes, shortcutsRes] = await Promise.race([homeDataPromise, timeoutPromise]) as [any, any, any];
const recentWithPinnedStatus = (recentRes || []).map(file => ({
...file,
@@ -181,7 +207,7 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
loading: false,
error: undefined
} : t));
await addConfigEditorRecent({
await addFileManagerRecent({
name: file.name,
path: file.path,
isSSH: true,
@@ -191,7 +217,8 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
fetchHomeData();
} catch (err: any) {
const errorMessage = formatErrorMessage(err, 'Cannot read file');
setTabs(tabs => tabs.map(t => t.id === tabId ? {...t, loading: false, error: errorMessage} : t));
toast.error(errorMessage);
setTabs(tabs => tabs.map(t => t.id === tabId ? {...t, loading: false} : t));
}
}
setActiveTab(tabId);
@@ -199,7 +226,7 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
const handleRemoveRecent = async (file: any) => {
try {
await removeConfigEditorRecent({
await removeFileManagerRecent({
name: file.name,
path: file.path,
isSSH: true,
@@ -213,7 +240,7 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
const handlePinFile = async (file: any) => {
try {
await addConfigEditorPinned({
await addFileManagerPinned({
name: file.name,
path: file.path,
isSSH: true,
@@ -230,7 +257,7 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
const handleUnpinFile = async (file: any) => {
try {
await removeConfigEditorPinned({
await removeFileManagerPinned({
name: file.name,
path: file.path,
isSSH: true,
@@ -270,7 +297,7 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
const handleAddShortcut = async (folderPath: string) => {
try {
const name = folderPath.split('/').pop() || folderPath;
await addConfigEditorShortcut({
await addFileManagerShortcut({
name,
path: folderPath,
isSSH: true,
@@ -284,7 +311,7 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
const handleRemoveShortcut = async (shortcut: any) => {
try {
await removeConfigEditorShortcut({
await removeFileManagerShortcut({
name: shortcut.name,
path: shortcut.path,
isSSH: true,
@@ -345,7 +372,7 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
setTimeout(() => reject(new Error('SSH status check timed out')), 10000)
);
const status = await Promise.race([statusPromise, statusTimeoutPromise]);
const status = await Promise.race([statusPromise, statusTimeoutPromise]) as { connected: boolean };
if (!status.connected) {
const connectPromise = connectSSH(tab.sshSessionId, {
@@ -375,18 +402,15 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
const result = await Promise.race([savePromise, timeoutPromise]);
setTabs(tabs => tabs.map(t => t.id === tab.id ? {
...t,
dirty: false,
success: 'File saved successfully'
loading: false
} : t));
setTimeout(() => {
setTabs(tabs => tabs.map(t => t.id === tab.id ? {...t, success: undefined} : t));
}, 3000);
toast.success('File saved successfully');
Promise.allSettled([
(async () => {
try {
await addConfigEditorRecent({
await addFileManagerRecent({
name: tab.fileName,
path: tab.filePath,
isSSH: true,
@@ -412,38 +436,73 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
errorMessage = `Save operation timed out. The file may have been saved successfully, but the operation took too long to complete. Check the Docker logs for confirmation.`;
}
setTabs(tabs => {
const updatedTabs = tabs.map(t => t.id === tab.id ? {
...t,
error: `Failed to save file: ${errorMessage}`
} : t);
return updatedTabs;
});
setTimeout(() => {
setTabs(currentTabs => [...currentTabs]);
}, 100);
toast.error(`Failed to save file: ${errorMessage}`);
setTabs(tabs => tabs.map(t => t.id === tab.id ? {
...t,
loading: false
} : t));
} finally {
setIsSaving(false);
}
};
const handleHostChange = (host: SSHHost | null) => {
setCurrentHost(host);
setTabs([]);
setActiveTab('home');
const handleHostChange = (_host: SSHHost | null) => {
};
const handleOperationComplete = () => {
if (sidebarRef.current && sidebarRef.current.fetchFiles) {
sidebarRef.current.fetchFiles();
}
if (currentHost) {
fetchHomeData();
}
};
const handleSuccess = (message: string) => {
toast.success(message);
};
const handleError = (error: string) => {
toast.error(error);
};
const updateCurrentPath = (newPath: string) => {
setCurrentPath(newPath);
};
const handleDeleteFromSidebar = (item: any) => {
setDeletingItem(item);
};
const performDelete = async (item: any) => {
if (!currentHost?.id) return;
try {
const {deleteSSHItem} = await import('@/ui/main-axios.ts');
await deleteSSHItem(currentHost.id.toString(), item.path, item.type === 'directory');
toast.success(`${item.type === 'directory' ? 'Folder' : 'File'} deleted successfully`);
setDeletingItem(null);
handleOperationComplete();
} catch (error: any) {
handleError(error?.response?.data?.error || 'Failed to delete item');
}
};
if (!currentHost) {
return (
<div style={{position: 'relative', width: '100vw', height: '100vh', overflow: 'hidden'}}>
<div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100vh', zIndex: 20}}>
<ConfigEditorSidebar
onSelectView={onSelectView}
<div style={{position: 'absolute', inset: 0, overflow: 'hidden'}} className="rounded-md">
<div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100%', zIndex: 20}}>
<FileManagerLeftSidebar
onSelectView={onSelectView || (() => {
})}
onOpenFile={handleOpenFile}
tabs={tabs}
ref={sidebarRef}
onHostChange={handleHostChange}
host={initialHost as SSHHost}
onOperationComplete={handleOperationComplete}
onError={handleError}
onSuccess={handleSuccess}
onPathChange={updateCurrentPath}
/>
</div>
<div style={{
@@ -467,55 +526,67 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
}
return (
<div style={{position: 'relative', width: '100vw', height: '100vh', overflow: 'hidden'}}>
<div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100vh', zIndex: 20}}>
<ConfigEditorSidebar
onSelectView={onSelectView}
<div style={{position: 'absolute', inset: 0, overflow: 'hidden'}} className="rounded-md">
<div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100%', zIndex: 20}}>
<FileManagerLeftSidebar
onSelectView={onSelectView || (() => {
})}
onOpenFile={handleOpenFile}
tabs={tabs}
ref={sidebarRef}
onHostChange={handleHostChange}
host={currentHost as SSHHost}
onOperationComplete={handleOperationComplete}
onError={handleError}
onSuccess={handleSuccess}
onPathChange={updateCurrentPath}
onDeleteItem={handleDeleteFromSidebar}
/>
</div>
<div style={{position: 'absolute', top: 0, left: 256, right: 0, height: 44, zIndex: 30}}>
<div className="flex items-center w-full bg-[#18181b] border-b border-[#222224] h-11 relative px-4"
style={{height: 44}}>
{/* Tab list scrollable area */}
<div className="flex-1 min-w-0 h-full flex items-center">
<div
className="h-9 w-full bg-[#09090b] border border-[#23232a] rounded-md flex items-center overflow-x-auto scrollbar-thin scrollbar-thumb-muted-foreground/30 scrollbar-track-transparent"
style={{minWidth: 0}}>
<ConfigTopbar
tabs={tabs.map(t => ({id: t.id, title: t.title}))}
activeTab={activeTab}
setActiveTab={setActiveTab}
closeTab={closeTab}
onHomeClick={() => {
setActiveTab('home');
if (currentHost) {
fetchHomeData();
}
}}
/>
</div>
<div style={{position: 'absolute', top: 0, left: 256, right: 0, height: 50, zIndex: 30}}>
<div className="flex items-center w-full bg-[#18181b] border-b-2 border-[#303032] h-[50px] relative">
<div
className="h-full p-1 pr-2 border-r-2 border-[#303032] w-[calc(100%-6rem)] flex items-center overflow-x-auto overflow-y-hidden gap-2 thin-scrollbar">
<FIleManagerTopNavbar
tabs={tabs.map(t => ({id: t.id, title: t.title}))}
activeTab={activeTab}
setActiveTab={setActiveTab}
closeTab={closeTab}
onHomeClick={() => {
setActiveTab('home');
if (currentHost) {
fetchHomeData();
}
}}
/>
</div>
<div className="flex items-center justify-center gap-2 flex-1">
<Button
variant="outline"
onClick={() => setShowOperations(!showOperations)}
className={cn(
'w-[30px] h-[30px]',
showOperations ? 'bg-[#2d2d30] border-[#434345]' : ''
)}
title="File Operations"
>
<Settings className="h-4 w-4"/>
</Button>
<div className="p-0.25 w-px h-[30px] bg-[#303032]"></div>
<Button
variant="outline"
onClick={() => {
const tab = tabs.find(t => t.id === activeTab);
if (tab && !isSaving) handleSave(tab);
}}
disabled={activeTab === 'home' || !tabs.find(t => t.id === activeTab)?.dirty || isSaving}
className={cn(
'w-[30px] h-[30px]',
activeTab === 'home' || !tabs.find(t => t.id === activeTab)?.dirty || isSaving ? 'opacity-60 cursor-not-allowed' : ''
)}
>
{isSaving ? <RefreshCw className="h-4 w-4 animate-spin"/> : <Save className="h-4 w-4"/>}
</Button>
</div>
{/* Save button - always visible */}
<Button
className={cn(
'ml-4 px-4 py-1.5 border rounded-md text-sm font-medium transition-colors',
'border-[#2d2d30] text-white bg-transparent hover:bg-[#23232a] active:bg-[#23232a] focus:bg-[#23232a]',
activeTab === 'home' || !tabs.find(t => t.id === activeTab)?.dirty || isSaving ? 'opacity-60 cursor-not-allowed' : 'hover:border-[#2d2d30]'
)}
disabled={activeTab === 'home' || !tabs.find(t => t.id === activeTab)?.dirty || isSaving}
onClick={() => {
const tab = tabs.find(t => t.id === activeTab);
if (tab && !isSaving) handleSave(tab);
}}
type="button"
style={{height: 36, alignSelf: 'center'}}
>
{isSaving ? 'Saving...' : 'Save'}
</Button>
</div>
</div>
<div style={{
@@ -530,79 +601,90 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
display: 'flex',
flexDirection: 'column'
}}>
{activeTab === 'home' ? (
<ConfigHomeView
recent={recent}
pinned={pinned}
shortcuts={shortcuts}
onOpenFile={handleOpenFile}
onRemoveRecent={handleRemoveRecent}
onPinFile={handlePinFile}
onUnpinFile={handleUnpinFile}
onOpenShortcut={handleOpenShortcut}
onRemoveShortcut={handleRemoveShortcut}
onAddShortcut={handleAddShortcut}
/>
) : (
(() => {
const tab = tabs.find(t => t.id === activeTab);
if (!tab) return null;
return (
<div className="flex flex-col h-full" style={{flex: 1, minHeight: 0}}>
{/* Error display */}
{tab.error && (
<div
className="bg-red-900/20 border border-red-500/30 text-red-300 px-4 py-3 text-sm">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-red-400"></span>
<span>{tab.error}</span>
</div>
<button
onClick={() => setTabs(tabs => tabs.map(t => t.id === tab.id ? {
...t,
error: undefined
} : t))}
className="text-red-400 hover:text-red-300 transition-colors"
>
</button>
<div className="flex h-full">
<div className="flex-1">
{activeTab === 'home' ? (
<FileManagerHomeView
recent={recent}
pinned={pinned}
shortcuts={shortcuts}
onOpenFile={handleOpenFile}
onRemoveRecent={handleRemoveRecent}
onPinFile={handlePinFile}
onUnpinFile={handleUnpinFile}
onOpenShortcut={handleOpenShortcut}
onRemoveShortcut={handleRemoveShortcut}
onAddShortcut={handleAddShortcut}
/>
) : (
(() => {
const tab = tabs.find(t => t.id === activeTab);
if (!tab) return null;
return (
<div className="flex flex-col h-full" style={{flex: 1, minHeight: 0}}>
<div className="flex-1 min-h-0">
<FileManagerFileEditor
content={tab.content}
fileName={tab.fileName}
onContentChange={content => setTabContent(tab.id, content)}
/>
</div>
</div>
)}
{/* Success display */}
{tab.success && (
<div
className="bg-green-900/20 border border-green-500/30 text-green-300 px-4 py-3 text-sm">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-green-400"></span>
<span>{tab.success}</span>
</div>
<button
onClick={() => setTabs(tabs => tabs.map(t => t.id === tab.id ? {
...t,
success: undefined
} : t))}
className="text-green-400 hover:text-green-300 transition-colors"
>
</button>
</div>
</div>
)}
<div className="flex-1 min-h-0">
<ConfigCodeEditor
content={tab.content}
fileName={tab.fileName}
onContentChange={content => setTabContent(tab.id, content)}
/>
</div>
</div>
);
})()
)}
);
})()
)}
</div>
{showOperations && (
<div className="w-80 border-l-2 border-[#303032] bg-[#09090b] overflow-y-auto">
<FileManagerOperations
currentPath={currentPath}
sshSessionId={currentHost?.id.toString() || null}
onOperationComplete={handleOperationComplete}
onError={handleError}
onSuccess={handleSuccess}
/>
</div>
)}
</div>
</div>
{deletingItem && (
<div className="fixed inset-0 z-[99999]">
<div className="absolute inset-0 bg-black/60"></div>
<div className="relative h-full flex items-center justify-center">
<div className="bg-[#18181b] border-2 border-[#303032] rounded-lg p-6 max-w-md mx-4 shadow-2xl">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Trash2 className="w-5 h-5 text-red-400"/>
Confirm Delete
</h3>
<p className="text-white mb-4">
Are you sure you want to delete <strong>{deletingItem.name}</strong>?
{deletingItem.type === 'directory' && ' This will delete the folder and all its contents.'}
</p>
<p className="text-red-400 text-sm mb-6">
This action cannot be undone.
</p>
<div className="flex gap-3">
<Button
variant="destructive"
onClick={() => performDelete(deletingItem)}
className="flex-1"
>
Delete
</Button>
<Button
variant="outline"
onClick={() => setDeletingItem(null)}
className="flex-1"
>
Cancel
</Button>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -5,13 +5,13 @@ import {hyperLink} from '@uiw/codemirror-extensions-hyper-link';
import {oneDark} from '@codemirror/theme-one-dark';
import {EditorView} from '@codemirror/view';
interface ConfigCodeEditorProps {
interface FileManagerCodeEditorProps {
content: string;
fileName: string;
onContentChange: (value: string) => void;
}
export function ConfigCodeEditor({content, fileName, onContentChange}: ConfigCodeEditorProps) {
export function FileManagerFileEditor({content, fileName, onContentChange}: FileManagerCodeEditorProps) {
function getLanguageName(filename: string): string {
if (!filename || typeof filename !== 'string') {
return 'text';

View File

@@ -18,7 +18,7 @@ interface ShortcutItem {
path: string;
}
interface ConfigHomeViewProps {
interface FileManagerHomeViewProps {
recent: FileItem[];
pinned: FileItem[];
shortcuts: ShortcutItem[];
@@ -31,25 +31,25 @@ interface ConfigHomeViewProps {
onAddShortcut: (path: string) => void;
}
export function ConfigHomeView({
recent,
pinned,
shortcuts,
onOpenFile,
onRemoveRecent,
onPinFile,
onUnpinFile,
onOpenShortcut,
onRemoveShortcut,
onAddShortcut
}: ConfigHomeViewProps) {
export function FileManagerHomeView({
recent,
pinned,
shortcuts,
onOpenFile,
onRemoveRecent,
onPinFile,
onUnpinFile,
onOpenShortcut,
onRemoveShortcut,
onAddShortcut
}: FileManagerHomeViewProps) {
const [tab, setTab] = useState<'recent' | 'pinned' | 'shortcuts'>('recent');
const [newShortcut, setNewShortcut] = useState('');
const renderFileCard = (file: FileItem, onRemove: () => void, onPin?: () => void, isPinned = false) => (
<div key={file.path}
className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border border-[#23232a] rounded hover:border-[#434345] transition-colors">
className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border-2 border-[#303032] rounded hover:border-[#434345] transition-colors">
<div
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
onClick={() => onOpenFile(file)}
@@ -92,7 +92,7 @@ export function ConfigHomeView({
const renderShortcutCard = (shortcut: ShortcutItem) => (
<div key={shortcut.path}
className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border border-[#23232a] rounded hover:border-[#434345] transition-colors">
className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border-2 border-[#303032] rounded hover:border-[#434345] transition-colors">
<div
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
onClick={() => onOpenShortcut(shortcut)}
@@ -120,7 +120,7 @@ export function ConfigHomeView({
return (
<div className="p-4 flex flex-col gap-4 h-full bg-[#09090b]">
<Tabs value={tab} onValueChange={v => setTab(v as 'recent' | 'pinned' | 'shortcuts')} className="w-full">
<TabsList className="mb-4 bg-[#18181b] border border-[#23232a]">
<TabsList className="mb-4 bg-[#18181b] border-2 border-[#303032]">
<TabsTrigger value="recent" className="data-[state=active]:bg-[#23232a]">Recent</TabsTrigger>
<TabsTrigger value="pinned" className="data-[state=active]:bg-[#23232a]">Pinned</TabsTrigger>
<TabsTrigger value="shortcuts" className="data-[state=active]:bg-[#23232a]">Folder
@@ -128,7 +128,8 @@ export function ConfigHomeView({
</TabsList>
<TabsContent value="recent" className="mt-0">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
<div
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{recent.length === 0 ? (
<div className="flex items-center justify-center py-8 col-span-full">
<span className="text-sm text-muted-foreground">No recent files.</span>
@@ -145,7 +146,8 @@ export function ConfigHomeView({
</TabsContent>
<TabsContent value="pinned" className="mt-0">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
<div
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{pinned.length === 0 ? (
<div className="flex items-center justify-center py-8 col-span-full">
<span className="text-sm text-muted-foreground">No pinned files.</span>
@@ -162,12 +164,12 @@ export function ConfigHomeView({
</TabsContent>
<TabsContent value="shortcuts" className="mt-0">
<div className="flex items-center gap-3 mb-4 p-3 bg-[#18181b] border border-[#23232a] rounded-lg">
<div className="flex items-center gap-3 mb-4 p-3 bg-[#18181b] border-2 border-[#303032] rounded-lg">
<Input
placeholder="Enter folder path"
value={newShortcut}
onChange={e => setNewShortcut(e.target.value)}
className="flex-1 bg-[#23232a] border-[#434345] text-white placeholder:text-muted-foreground"
className="flex-1 bg-[#23232a] border-2 border-[#303032] text-white placeholder:text-muted-foreground"
onKeyDown={(e) => {
if (e.key === 'Enter' && newShortcut.trim()) {
onAddShortcut(newShortcut.trim());
@@ -177,8 +179,8 @@ export function ConfigHomeView({
/>
<Button
size="sm"
variant="outline"
className="h-8 px-2 bg-[#23232a] border-[#434345] hover:bg-[#2d2d30] rounded-md"
variant="ghost"
className="h-8 px-2 bg-[#23232a] border-2 !border-[#303032] hover:bg-[#2d2d30] rounded-md"
onClick={() => {
if (newShortcut.trim()) {
onAddShortcut(newShortcut.trim());
@@ -190,7 +192,8 @@ export function ConfigHomeView({
Add
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
<div
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{shortcuts.length === 0 ? (
<div className="flex items-center justify-center py-4 col-span-full">
<span className="text-sm text-muted-foreground">No shortcuts.</span>

View File

@@ -0,0 +1,572 @@
import React, {useEffect, useState, useRef, forwardRef, useImperativeHandle} from 'react';
import {Separator} from '@/components/ui/separator.tsx';
import {CornerDownLeft, Folder, File, Server, ArrowUp, Pin, MoreVertical, Trash2, Edit3} from 'lucide-react';
import {ScrollArea} from '@/components/ui/scroll-area.tsx';
import {cn} from '@/lib/utils.ts';
import {Input} from '@/components/ui/input.tsx';
import {Button} from '@/components/ui/button.tsx';
import {toast} from 'sonner';
import {
listSSHFiles,
renameSSHItem,
deleteSSHItem,
getFileManagerRecent,
getFileManagerPinned,
addFileManagerPinned,
removeFileManagerPinned,
readSSHFile,
getSSHStatus,
connectSSH
} from '@/ui/main-axios.ts';
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
updatedAt: string;
}
const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
{onSelectView, onOpenFile, tabs, host, onOperationComplete, onError, onSuccess, onPathChange, onDeleteItem}: {
onSelectView?: (view: string) => void;
onOpenFile: (file: any) => void;
tabs: any[];
host: SSHHost;
onOperationComplete?: () => void;
onError?: (error: string) => void;
onSuccess?: (message: string) => void;
onPathChange?: (path: string) => void;
onDeleteItem?: (item: any) => void;
},
ref
) {
const [currentPath, setCurrentPath] = useState('/');
const [files, setFiles] = useState<any[]>([]);
const pathInputRef = useRef<HTMLInputElement>(null);
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [fileSearch, setFileSearch] = useState('');
const [debouncedFileSearch, setDebouncedFileSearch] = useState('');
useEffect(() => {
const handler = setTimeout(() => setDebouncedSearch(search), 200);
return () => clearTimeout(handler);
}, [search]);
useEffect(() => {
const handler = setTimeout(() => setDebouncedSearch(fileSearch), 200);
return () => clearTimeout(handler);
}, [fileSearch]);
const [sshSessionId, setSshSessionId] = useState<string | null>(null);
const [filesLoading, setFilesLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [connectingSSH, setConnectingSSH] = useState(false);
const [connectionCache, setConnectionCache] = useState<Record<string, {
sessionId: string;
timestamp: number
}>>({});
const [fetchingFiles, setFetchingFiles] = useState(false);
const [contextMenu, setContextMenu] = useState<{
visible: boolean;
x: number;
y: number;
item: any;
}>({
visible: false,
x: 0,
y: 0,
item: null
});
const [renamingItem, setRenamingItem] = useState<{
item: any;
newName: string;
} | null>(null);
useEffect(() => {
const nextPath = host?.defaultPath || '/';
setCurrentPath(nextPath);
onPathChange?.(nextPath);
(async () => {
await connectToSSH(host);
})();
}, [host?.id]);
async function connectToSSH(server: SSHHost): Promise<string | null> {
const sessionId = server.id.toString();
const cached = connectionCache[sessionId];
if (cached && Date.now() - cached.timestamp < 30000) {
setSshSessionId(cached.sessionId);
return cached.sessionId;
}
if (connectingSSH) {
return null;
}
setConnectingSSH(true);
try {
if (!server.password && !server.key) {
toast.error('No authentication credentials available for this SSH host');
return null;
}
const connectionConfig = {
ip: server.ip,
port: server.port,
username: server.username,
password: server.password,
sshKey: server.key,
keyPassword: server.keyPassword,
};
await connectSSH(sessionId, connectionConfig);
setSshSessionId(sessionId);
setConnectionCache(prev => ({
...prev,
[sessionId]: {sessionId, timestamp: Date.now()}
}));
return sessionId;
} catch (err: any) {
toast.error(err?.response?.data?.error || 'Failed to connect to SSH');
setSshSessionId(null);
return null;
} finally {
setConnectingSSH(false);
}
}
async function fetchFiles() {
if (fetchingFiles) {
return;
}
setFetchingFiles(true);
setFiles([]);
setFilesLoading(true);
try {
let pinnedFiles: any[] = [];
try {
if (host) {
pinnedFiles = await getFileManagerPinned(host.id);
}
} catch (err) {
}
if (host && sshSessionId) {
let res: any[] = [];
try {
const status = await getSSHStatus(sshSessionId);
if (!status.connected) {
const newSessionId = await connectToSSH(host);
if (newSessionId) {
setSshSessionId(newSessionId);
res = await listSSHFiles(newSessionId, currentPath);
} else {
throw new Error('Failed to reconnect SSH session');
}
} else {
res = await listSSHFiles(sshSessionId, currentPath);
}
} catch (sessionErr) {
const newSessionId = await connectToSSH(host);
if (newSessionId) {
setSshSessionId(newSessionId);
res = await listSSHFiles(newSessionId, currentPath);
} else {
throw sessionErr;
}
}
const processedFiles = (res || []).map((f: any) => {
const filePath = currentPath + (currentPath.endsWith('/') ? '' : '/') + f.name;
const isPinned = pinnedFiles.some(pinned => pinned.path === filePath);
return {
...f,
path: filePath,
isPinned,
isSSH: true,
sshSessionId: sshSessionId
};
});
setFiles(processedFiles);
}
} catch (err: any) {
setFiles([]);
toast.error(err?.response?.data?.error || err?.message || 'Failed to list files');
} finally {
setFilesLoading(false);
setFetchingFiles(false);
}
}
useEffect(() => {
if (host && sshSessionId && !connectingSSH && !fetchingFiles) {
const timeoutId = setTimeout(() => {
fetchFiles();
}, 100);
return () => clearTimeout(timeoutId);
}
}, [currentPath, host, sshSessionId]);
useImperativeHandle(ref, () => ({
openFolder: async (_server: SSHHost, path: string) => {
if (connectingSSH || fetchingFiles) {
return;
}
if (currentPath === path) {
setTimeout(() => fetchFiles(), 100);
return;
}
setFetchingFiles(false);
setFilesLoading(false);
setFiles([]);
setCurrentPath(path);
onPathChange?.(path);
if (!sshSessionId) {
const sessionId = await connectToSSH(host);
if (sessionId) setSshSessionId(sessionId);
}
},
fetchFiles: () => {
if (host && sshSessionId) {
fetchFiles();
}
},
getCurrentPath: () => currentPath
}));
useEffect(() => {
if (pathInputRef.current) {
pathInputRef.current.scrollLeft = pathInputRef.current.scrollWidth;
}
}, [currentPath]);
const filteredFiles = files.filter(file => {
const q = debouncedFileSearch.trim().toLowerCase();
if (!q) return true;
return file.name.toLowerCase().includes(q);
});
const handleContextMenu = (e: React.MouseEvent, item: any) => {
e.preventDefault();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const menuWidth = 160;
const menuHeight = 80;
let x = e.clientX;
let y = e.clientY;
if (x + menuWidth > viewportWidth) {
x = e.clientX - menuWidth;
}
if (y + menuHeight > viewportHeight) {
y = e.clientY - menuHeight;
}
if (x < 0) {
x = 0;
}
if (y < 0) {
y = 0;
}
setContextMenu({
visible: true,
x,
y,
item
});
};
const closeContextMenu = () => {
setContextMenu({ visible: false, x: 0, y: 0, item: null });
};
const handleRename = async (item: any, newName: string) => {
if (!sshSessionId || !newName.trim() || newName === item.name) {
setRenamingItem(null);
return;
}
try {
await renameSSHItem(sshSessionId, item.path, newName.trim());
toast.success(`${item.type === 'directory' ? 'Folder' : 'File'} renamed successfully`);
setRenamingItem(null);
if (onOperationComplete) {
onOperationComplete();
} else {
fetchFiles();
}
} catch (error: any) {
toast.error(error?.response?.data?.error || 'Failed to rename item');
}
};
const handleDelete = async (item: any) => {
if (!sshSessionId) return;
try {
await deleteSSHItem(sshSessionId, item.path, item.type === 'directory');
toast.success(`${item.type === 'directory' ? 'Folder' : 'File'} deleted successfully`);
if (onOperationComplete) {
onOperationComplete();
} else {
fetchFiles();
}
} catch (error: any) {
toast.error(error?.response?.data?.error || 'Failed to delete item');
}
};
const startRename = (item: any) => {
setRenamingItem({ item, newName: item.name });
closeContextMenu();
};
const startDelete = (item: any) => {
onDeleteItem?.(item);
closeContextMenu();
};
useEffect(() => {
const handleClickOutside = () => closeContextMenu();
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}, []);
const handlePathChange = (newPath: string) => {
setCurrentPath(newPath);
onPathChange?.(newPath);
};
return (
<div className="flex flex-col h-full w-[256px]" style={{maxWidth: 256}}>
<div className="flex flex-col flex-grow min-h-0">
<div className="flex-1 w-full h-full flex flex-col bg-[#09090b] border-r-2 border-[#303032] overflow-hidden p-0 relative min-h-0">
{host && (
<div className="flex flex-col h-full w-full" style={{maxWidth: 260}}>
<div className="flex items-center gap-2 px-2 py-1.5 border-b-2 border-[#303032] bg-[#18181b] z-20" style={{maxWidth: 260}}>
<Button
size="icon"
variant="outline"
className="h-9 w-9 bg-[#18181b] border-2 border-[#303032] rounded-md hover:bg-[#2d2d30] focus:outline-none focus:ring-2 focus:ring-ring"
onClick={() => {
let path = currentPath;
if (path && path !== '/' && path !== '') {
if (path.endsWith('/')) path = path.slice(0, -1);
const lastSlash = path.lastIndexOf('/');
if (lastSlash > 0) {
handlePathChange(path.slice(0, lastSlash));
} else {
handlePathChange('/');
}
} else {
handlePathChange('/');
}
}}
>
<ArrowUp className="w-4 h-4"/>
</Button>
<Input ref={pathInputRef} value={currentPath}
onChange={e => handlePathChange(e.target.value)}
className="flex-1 bg-[#18181b] border-2 border-[#434345] text-white truncate rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring hover:border-[#5a5a5d]"
/>
</div>
<div className="px-2 py-2 border-b-1 border-[#303032] bg-[#18181b]">
<Input
placeholder="Search files and folders..."
className="w-full h-7 text-sm bg-[#23232a] border-2 border-[#434345] text-white placeholder:text-muted-foreground rounded-md"
autoComplete="off"
value={fileSearch}
onChange={e => setFileSearch(e.target.value)}
/>
</div>
<div className="flex-1 min-h-0 w-full bg-[#09090b] border-t-1 border-[#303032]">
<ScrollArea className="h-full w-full bg-[#09090b]">
<div className="p-2 pb-0">
{connectingSSH || filesLoading ? (
<div className="text-xs text-muted-foreground">Loading...</div>
) : filteredFiles.length === 0 ? (
<div className="text-xs text-muted-foreground">No files or folders found.</div>
) : (
<div className="flex flex-col gap-1">
{filteredFiles.map((item: any) => {
const isOpen = (tabs || []).some((t: any) => t.id === item.path);
const isRenaming = renamingItem?.item?.path === item.path;
const isDeleting = false;
return (
<div
key={item.path}
className={cn(
"flex items-center gap-2 px-3 py-2 bg-[#18181b] border-2 border-[#303032] rounded group max-w-full relative",
isOpen && "opacity-60 cursor-not-allowed pointer-events-none"
)}
style={{maxWidth: 220, marginBottom: 8}}
onContextMenu={(e) => !isOpen && handleContextMenu(e, item)}
>
{isRenaming ? (
<div className="flex items-center gap-2 flex-1 min-w-0">
{item.type === 'directory' ?
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0"/> :
<File className="w-4 h-4 text-muted-foreground flex-shrink-0"/>}
<Input
value={renamingItem.newName}
onChange={(e) => setRenamingItem(prev => prev ? {...prev, newName: e.target.value} : null)}
className="flex-1 h-6 text-sm bg-[#23232a] border border-[#434345] text-white"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleRename(item, renamingItem.newName);
} else if (e.key === 'Escape') {
setRenamingItem(null);
}
}}
onBlur={() => handleRename(item, renamingItem.newName)}
/>
</div>
) : (
<>
<div
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
onClick={() => !isOpen && (item.type === 'directory' ? handlePathChange(item.path) : onOpenFile({
name: item.name,
path: item.path,
isSSH: item.isSSH,
sshSessionId: item.sshSessionId
}))}
>
{item.type === 'directory' ?
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0"/> :
<File className="w-4 h-4 text-muted-foreground flex-shrink-0"/>}
<span className="text-sm text-white truncate flex-1 min-w-0">{item.name}</span>
</div>
<div className="flex items-center gap-1">
{item.type === 'file' && (
<Button size="icon" variant="ghost" className="h-7 w-7"
disabled={isOpen}
onClick={async (e) => {
e.stopPropagation();
try {
if (item.isPinned) {
await removeFileManagerPinned({
name: item.name,
path: item.path,
hostId: host?.id,
isSSH: true,
sshSessionId: host?.id.toString()
});
setFiles(files.map(f =>
f.path === item.path ? { ...f, isPinned: false } : f
));
} else {
await addFileManagerPinned({
name: item.name,
path: item.path,
hostId: host?.id,
isSSH: true,
sshSessionId: host?.id.toString()
});
setFiles(files.map(f =>
f.path === item.path ? { ...f, isPinned: true } : f
));
}
} catch (err) {
}
}}
>
<Pin className={`w-1 h-1 ${item.isPinned ? 'text-yellow-400 fill-current' : 'text-muted-foreground'}`}/>
</Button>
)}
{!isOpen && (
<Button
size="icon"
variant="ghost"
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
handleContextMenu(e, item);
}}
>
<MoreVertical className="w-4 h-4" />
</Button>
)}
</div>
</>
)}
</div>
);
})}
</div>
)}
</div>
</ScrollArea>
</div>
</div>
)}
</div>
</div>
{contextMenu.visible && contextMenu.item && (
<div
className="fixed z-[99998] bg-[#18181b] border-2 border-[#303032] rounded-lg shadow-xl py-1 min-w-[160px]"
style={{
left: contextMenu.x,
top: contextMenu.y,
}}
>
<button
className="w-full px-3 py-2 text-left text-sm text-white hover:bg-[#2d2d30] flex items-center gap-2"
onClick={() => startRename(contextMenu.item)}
>
<Edit3 className="w-4 h-4" />
Rename
</button>
<button
className="w-full px-3 py-2 text-left text-sm text-red-400 hover:bg-[#2d2d30] flex items-center gap-2"
onClick={() => startDelete(contextMenu.item)}
>
<Trash2 className="w-4 h-4" />
Delete
</button>
</div>
)}
</div>
);
});
export {FileManagerLeftSidebar};

View File

@@ -20,7 +20,7 @@ interface FileItem {
isStarred?: boolean;
}
interface ConfigFileSidebarViewerProps {
interface FileManagerLeftSidebarVileViewerProps {
sshConnections: SSHConnection[];
onAddSSH: () => void;
onConnectSSH: (conn: SSHConnection) => void;
@@ -41,70 +41,28 @@ interface ConfigFileSidebarViewerProps {
currentSSH?: SSHConnection;
}
export function ConfigFileSidebarViewer({
sshConnections,
onAddSSH,
onConnectSSH,
onEditSSH,
onDeleteSSH,
onPinSSH,
currentPath,
files,
onOpenFile,
onOpenFolder,
onStarFile,
onDeleteFile,
isLoading,
error,
isSSHMode,
onSwitchToLocal,
onSwitchToSSH,
currentSSH,
}: ConfigFileSidebarViewerProps) {
export function FileManagerLeftSidebarFileViewer({
sshConnections,
onAddSSH,
onConnectSSH,
onEditSSH,
onDeleteSSH,
onPinSSH,
currentPath,
files,
onOpenFile,
onOpenFolder,
onStarFile,
onDeleteFile,
isLoading,
error,
isSSHMode,
onSwitchToLocal,
onSwitchToSSH,
currentSSH,
}: FileManagerLeftSidebarVileViewerProps) {
return (
<div className="flex flex-col h-full">
{/* SSH Connections */}
<div className="p-2 bg-[#18181b] border-b border-[#23232a]">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-muted-foreground font-semibold">SSH Connections</span>
<Button size="icon" variant="outline" onClick={onAddSSH} className="ml-2 h-7 w-7">
<Plus className="w-4 h-4"/>
</Button>
</div>
<div className="flex flex-col gap-1">
<Button
variant={!isSSHMode ? 'secondary' : 'ghost'}
className="w-full justify-start text-left px-2 py-1.5 rounded"
onClick={onSwitchToLocal}
>
<Server className="w-4 h-4 mr-2"/> Local Files
</Button>
{sshConnections.map((conn) => (
<div key={conn.id} className="flex items-center gap-1 group">
<Button
variant={isSSHMode && currentSSH?.id === conn.id ? 'secondary' : 'ghost'}
className="flex-1 justify-start text-left px-2 py-1.5 rounded"
onClick={() => onSwitchToSSH(conn)}
>
<Link2 className="w-4 h-4 mr-2"/>
{conn.name || conn.ip}
{conn.isPinned && <Pin className="w-3 h-3 ml-1 text-yellow-400"/>}
</Button>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onPinSSH(conn)}>
<Pin
className={`w-4 h-4 ${conn.isPinned ? 'text-yellow-400' : 'text-muted-foreground'}`}/>
</Button>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onEditSSH(conn)}>
<Edit className="w-4 h-4"/>
</Button>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onDeleteSSH(conn)}>
<Trash2 className="w-4 h-4 text-red-500"/>
</Button>
</div>
))}
</div>
</div>
{/* File/Folder Viewer */}
<div className="flex-1 bg-[#09090b] p-2 overflow-y-auto">
<div className="mb-2 flex items-center gap-2">
<span
@@ -119,7 +77,7 @@ export function ConfigFileSidebarViewer({
<div className="flex flex-col gap-1">
{files.map((item) => (
<Card key={item.path}
className="flex items-center gap-2 px-2 py-1 bg-[#18181b] border border-[#23232a] rounded">
className="flex items-center gap-2 px-2 py-1 bg-[#18181b] border-2 border-[#303032] rounded">
<div className="flex items-center gap-2 flex-1 cursor-pointer"
onClick={() => item.type === 'directory' ? onOpenFolder(item) : onOpenFile(item)}>
{item.type === 'directory' ? <Folder className="w-4 h-4 text-blue-400"/> :

View File

@@ -0,0 +1,624 @@
import React, {useState, useRef, useEffect} from 'react';
import {Button} from '@/components/ui/button.tsx';
import {Input} from '@/components/ui/input.tsx';
import {Card} from '@/components/ui/card.tsx';
import {Separator} from '@/components/ui/separator.tsx';
import {
Upload,
FilePlus,
FolderPlus,
Trash2,
Edit3,
X,
Check,
AlertCircle,
FileText,
Folder
} from 'lucide-react';
import {cn} from '@/lib/utils.ts';
interface FileManagerOperationsProps {
currentPath: string;
sshSessionId: string | null;
onOperationComplete: () => void;
onError: (error: string) => void;
onSuccess: (message: string) => void;
}
export function FileManagerOperations({
currentPath,
sshSessionId,
onOperationComplete,
onError,
onSuccess
}: FileManagerOperationsProps) {
const [showUpload, setShowUpload] = useState(false);
const [showCreateFile, setShowCreateFile] = useState(false);
const [showCreateFolder, setShowCreateFolder] = useState(false);
const [showDelete, setShowDelete] = useState(false);
const [showRename, setShowRename] = useState(false);
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [newFileName, setNewFileName] = useState('');
const [newFolderName, setNewFolderName] = useState('');
const [deletePath, setDeletePath] = useState('');
const [deleteIsDirectory, setDeleteIsDirectory] = useState(false);
const [renamePath, setRenamePath] = useState('');
const [renameIsDirectory, setRenameIsDirectory] = useState(false);
const [newName, setNewName] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [showTextLabels, setShowTextLabels] = useState(true);
const fileInputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const checkContainerWidth = () => {
if (containerRef.current) {
const width = containerRef.current.offsetWidth;
setShowTextLabels(width > 240);
}
};
checkContainerWidth();
const resizeObserver = new ResizeObserver(checkContainerWidth);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => {
resizeObserver.disconnect();
};
}, []);
const handleFileUpload = async () => {
if (!uploadFile || !sshSessionId) return;
setIsLoading(true);
try {
const content = await uploadFile.text();
const {uploadSSHFile} = await import('@/ui/main-axios.ts');
await uploadSSHFile(sshSessionId, currentPath, uploadFile.name, content);
onSuccess(`File "${uploadFile.name}" uploaded successfully`);
setShowUpload(false);
setUploadFile(null);
onOperationComplete();
} catch (error: any) {
onError(error?.response?.data?.error || 'Failed to upload file');
} finally {
setIsLoading(false);
}
};
const handleCreateFile = async () => {
if (!newFileName.trim() || !sshSessionId) return;
setIsLoading(true);
try {
const {createSSHFile} = await import('@/ui/main-axios.ts');
await createSSHFile(sshSessionId, currentPath, newFileName.trim());
onSuccess(`File "${newFileName.trim()}" created successfully`);
setShowCreateFile(false);
setNewFileName('');
onOperationComplete();
} catch (error: any) {
onError(error?.response?.data?.error || 'Failed to create file');
} finally {
setIsLoading(false);
}
};
const handleCreateFolder = async () => {
if (!newFolderName.trim() || !sshSessionId) return;
setIsLoading(true);
try {
const {createSSHFolder} = await import('@/ui/main-axios.ts');
await createSSHFolder(sshSessionId, currentPath, newFolderName.trim());
onSuccess(`Folder "${newFolderName.trim()}" created successfully`);
setShowCreateFolder(false);
setNewFolderName('');
onOperationComplete();
} catch (error: any) {
onError(error?.response?.data?.error || 'Failed to create folder');
} finally {
setIsLoading(false);
}
};
const handleDelete = async () => {
if (!deletePath || !sshSessionId) return;
setIsLoading(true);
try {
const {deleteSSHItem} = await import('@/ui/main-axios.ts');
await deleteSSHItem(sshSessionId, deletePath, deleteIsDirectory);
onSuccess(`${deleteIsDirectory ? 'Folder' : 'File'} deleted successfully`);
setShowDelete(false);
setDeletePath('');
setDeleteIsDirectory(false);
onOperationComplete();
} catch (error: any) {
onError(error?.response?.data?.error || 'Failed to delete item');
} finally {
setIsLoading(false);
}
};
const handleRename = async () => {
if (!renamePath || !newName.trim() || !sshSessionId) return;
setIsLoading(true);
try {
const {renameSSHItem} = await import('@/ui/main-axios.ts');
await renameSSHItem(sshSessionId, renamePath, newName.trim());
onSuccess(`${renameIsDirectory ? 'Folder' : 'File'} renamed successfully`);
setShowRename(false);
setRenamePath('');
setRenameIsDirectory(false);
setNewName('');
onOperationComplete();
} catch (error: any) {
onError(error?.response?.data?.error || 'Failed to rename item');
} finally {
setIsLoading(false);
}
};
const openFileDialog = () => {
fileInputRef.current?.click();
};
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
setUploadFile(file);
}
};
const resetStates = () => {
setShowUpload(false);
setShowCreateFile(false);
setShowCreateFolder(false);
setShowDelete(false);
setShowRename(false);
setUploadFile(null);
setNewFileName('');
setNewFolderName('');
setDeletePath('');
setDeleteIsDirectory(false);
setRenamePath('');
setRenameIsDirectory(false);
setNewName('');
};
if (!sshSessionId) {
return (
<div className="p-4 text-center">
<AlertCircle className="w-8 h-8 text-muted-foreground mx-auto mb-2"/>
<p className="text-sm text-muted-foreground">Connect to SSH to use file operations</p>
</div>
);
}
return (
<div ref={containerRef} className="p-4 space-y-4">
<div className="grid grid-cols-2 gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setShowUpload(true)}
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
title="Upload File"
>
<Upload className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")}/>
{showTextLabels && <span className="truncate">Upload File</span>}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowCreateFile(true)}
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
title="New File"
>
<FilePlus className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")}/>
{showTextLabels && <span className="truncate">New File</span>}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowCreateFolder(true)}
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
title="New Folder"
>
<FolderPlus className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")}/>
{showTextLabels && <span className="truncate">New Folder</span>}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowRename(true)}
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
title="Rename"
>
<Edit3 className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")}/>
{showTextLabels && <span className="truncate">Rename</span>}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowDelete(true)}
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30] col-span-2"
title="Delete Item"
>
<Trash2 className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")}/>
{showTextLabels && <span className="truncate">Delete Item</span>}
</Button>
</div>
<div className="bg-[#141416] border-2 border-[#373739] rounded-md p-3">
<div className="flex items-start gap-2 text-sm">
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5"/>
<div className="flex-1 min-w-0">
<span className="text-muted-foreground block mb-1">Current Path:</span>
<span className="text-white font-mono text-xs break-all leading-relaxed">{currentPath}</span>
</div>
</div>
</div>
<Separator className="p-0.25 bg-[#303032]"/>
{showUpload && (
<Card className="bg-[#18181b] border-2 border-[#303032] p-3 sm:p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2 mb-1">
<Upload className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0"/>
<span className="break-words">Upload File</span>
</h3>
<p className="text-xs text-muted-foreground break-words">
Max: 100MB (JSON) / 200MB (Binary)
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowUpload(false)}
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
>
<X className="w-4 h-4"/>
</Button>
</div>
<div className="space-y-3">
<div className="border-2 border-dashed border-[#434345] rounded-lg p-4 text-center">
{uploadFile ? (
<div className="space-y-3">
<FileText className="w-12 h-12 text-blue-400 mx-auto"/>
<p className="text-white font-medium text-sm break-words px-2">{uploadFile.name}</p>
<p className="text-xs text-muted-foreground">
{(uploadFile.size / 1024).toFixed(2)} KB
</p>
<Button
variant="outline"
size="sm"
onClick={() => setUploadFile(null)}
className="w-full text-sm h-8"
>
Remove File
</Button>
</div>
) : (
<div className="space-y-3">
<Upload className="w-12 h-12 text-muted-foreground mx-auto"/>
<p className="text-white text-sm break-words px-2">Click to select a file</p>
<Button
variant="outline"
size="sm"
onClick={openFileDialog}
className="w-full text-sm h-8"
>
Choose File
</Button>
</div>
)}
</div>
<input
ref={fileInputRef}
type="file"
onChange={handleFileSelect}
className="hidden"
accept="*/*"
/>
<div className="flex flex-col gap-2">
<Button
onClick={handleFileUpload}
disabled={!uploadFile || isLoading}
className="w-full text-sm h-9"
>
{isLoading ? 'Uploading...' : 'Upload File'}
</Button>
<Button
variant="outline"
onClick={() => setShowUpload(false)}
disabled={isLoading}
className="w-full text-sm h-9"
>
Cancel
</Button>
</div>
</div>
</Card>
)}
{showCreateFile && (
<Card className="bg-[#18181b] border-2 border-[#303032] p-3 sm:p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
<FilePlus className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0"/>
<span className="break-words">Create New File</span>
</h3>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowCreateFile(false)}
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
>
<X className="w-4 h-4"/>
</Button>
</div>
<div className="space-y-3">
<div>
<label className="text-sm font-medium text-white mb-2 block">
File Name
</label>
<Input
value={newFileName}
onChange={(e) => setNewFileName(e.target.value)}
placeholder="Enter file name (e.g., example.txt)"
className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
onKeyDown={(e) => e.key === 'Enter' && handleCreateFile()}
/>
</div>
<div className="flex flex-col gap-2">
<Button
onClick={handleCreateFile}
disabled={!newFileName.trim() || isLoading}
className="w-full text-sm h-9"
>
{isLoading ? 'Creating...' : 'Create File'}
</Button>
<Button
variant="outline"
onClick={() => setShowCreateFile(false)}
disabled={isLoading}
className="w-full text-sm h-9"
>
Cancel
</Button>
</div>
</div>
</Card>
)}
{showCreateFolder && (
<Card className="bg-[#18181b] border-2 border-[#303032] p-3">
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-white flex items-center gap-2">
<FolderPlus className="w-6 h-6 flex-shrink-0"/>
<span className="break-words">Create New Folder</span>
</h3>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowCreateFolder(false)}
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
>
<X className="w-4 h-4"/>
</Button>
</div>
<div className="space-y-3">
<div>
<label className="text-sm font-medium text-white mb-2 block">
Folder Name
</label>
<Input
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder="Enter folder name"
className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
onKeyDown={(e) => e.key === 'Enter' && handleCreateFolder()}
/>
</div>
<div className="flex flex-col gap-2">
<Button
onClick={handleCreateFolder}
disabled={!newFolderName.trim() || isLoading}
className="w-full text-sm h-9"
>
{isLoading ? 'Creating...' : 'Create Folder'}
</Button>
<Button
variant="outline"
onClick={() => setShowCreateFolder(false)}
disabled={isLoading}
className="w-full text-sm h-9"
>
Cancel
</Button>
</div>
</div>
</Card>
)}
{showDelete && (
<Card className="bg-[#18181b] border-2 border-[#303032] p-3">
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-white flex items-center gap-2">
<Trash2 className="w-6 h-6 text-red-400 flex-shrink-0"/>
<span className="break-words">Delete Item</span>
</h3>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowDelete(false)}
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
>
<X className="w-4 h-4"/>
</Button>
</div>
<div className="space-y-3">
<div className="bg-red-900/20 border border-red-500/30 rounded-lg p-3">
<div className="flex items-start gap-2 text-red-300">
<AlertCircle className="w-5 h-5 flex-shrink-0"/>
<span className="text-sm font-medium break-words">Warning: This action cannot be undone</span>
</div>
</div>
<div>
<label className="text-sm font-medium text-white mb-2 block">
Item Path
</label>
<Input
value={deletePath}
onChange={(e) => setDeletePath(e.target.value)}
placeholder="Enter full path to item"
className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
/>
</div>
<div className="flex items-start gap-2">
<input
type="checkbox"
id="deleteIsDirectory"
checked={deleteIsDirectory}
onChange={(e) => setDeleteIsDirectory(e.target.checked)}
className="rounded border-[#434345] bg-[#23232a] mt-0.5 flex-shrink-0"
/>
<label htmlFor="deleteIsDirectory" className="text-sm text-white break-words">
This is a directory (will delete recursively)
</label>
</div>
<div className="flex flex-col gap-2">
<Button
onClick={handleDelete}
disabled={!deletePath || isLoading}
variant="destructive"
className="w-full text-sm h-9"
>
{isLoading ? 'Deleting...' : 'Delete Item'}
</Button>
<Button
variant="outline"
onClick={() => setShowDelete(false)}
disabled={isLoading}
className="w-full text-sm h-9"
>
Cancel
</Button>
</div>
</div>
</Card>
)}
{showRename && (
<Card className="bg-[#18181b] border-2 border-[#303032] p-3">
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-white flex items-center gap-2">
<Edit3 className="w-6 h-6 flex-shrink-0"/>
<span className="break-words">Rename Item</span>
</h3>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowRename(false)}
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
>
<X className="w-4 h-4"/>
</Button>
</div>
<div className="space-y-3">
<div>
<label className="text-sm font-medium text-white mb-2 block">
Current Path
</label>
<Input
value={renamePath}
onChange={(e) => setRenamePath(e.target.value)}
placeholder="Enter current path to item"
className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
/>
</div>
<div>
<label className="text-sm font-medium text-white mb-2 block">
New Name
</label>
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Enter new name"
className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
onKeyDown={(e) => e.key === 'Enter' && handleRename()}
/>
</div>
<div className="flex items-start gap-2">
<input
type="checkbox"
id="renameIsDirectory"
checked={renameIsDirectory}
onChange={(e) => setRenameIsDirectory(e.target.checked)}
className="rounded border-[#434345] bg-[#23232a] mt-0.5 flex-shrink-0"
/>
<label htmlFor="renameIsDirectory" className="text-sm text-white break-words">
This is a directory
</label>
</div>
<div className="flex flex-col gap-2">
<Button
onClick={handleRename}
disabled={!renamePath || !newName.trim() || isLoading}
className="w-full text-sm h-9"
>
{isLoading ? 'Renaming...' : 'Rename Item'}
</Button>
<Button
variant="outline"
onClick={() => setShowRename(false)}
disabled={isLoading}
className="w-full text-sm h-9"
>
Cancel
</Button>
</div>
</div>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,52 @@
import React from 'react';
import {Button} from '@/components/ui/button.tsx';
import {X, Home} from 'lucide-react';
interface FileManagerTab {
id: string | number;
title: string;
}
interface FileManagerTabList {
tabs: FileManagerTab[];
activeTab: string | number;
setActiveTab: (tab: string | number) => void;
closeTab: (tab: string | number) => void;
onHomeClick: () => void;
}
export function FileManagerTabList({tabs, activeTab, setActiveTab, closeTab, onHomeClick}: FileManagerTabList) {
return (
<div className="inline-flex items-center h-full gap-2">
<Button
onClick={onHomeClick}
variant="outline"
className={`ml-1 h-8 rounded-md flex items-center !px-2 border-1 border-[#303032] ${activeTab === 'home' ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
>
<Home className="w-4 h-4"/>
</Button>
{tabs.map((tab) => {
const isActive = tab.id === activeTab;
return (
<div key={tab.id} className="inline-flex rounded-md shadow-sm" role="group">
<Button
onClick={() => setActiveTab(tab.id)}
variant="outline"
className={`h-8 rounded-r-none !px-2 border-1 border-[#303032] ${isActive ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
>
{tab.title}
</Button>
<Button
onClick={() => closeTab(tab.id)}
variant="outline"
className="h-8 rounded-l-none p-0 !w-9 border-1 border-[#303032]"
>
<X className="!w-4 !h-4" strokeWidth={2}/>
</Button>
</div>
);
})}
</div>
);
}

View File

@@ -1,12 +1,13 @@
import React, {useState} from "react";
import {SSHManagerSidebar} from "@/apps/SSH/Manager/SSHManagerSidebar.tsx";
import {SSHManagerHostViewer} from "@/apps/SSH/Manager/SSHManagerHostViewer.tsx"
import {HostManagerHostViewer} from "@/ui/Apps/Host Manager/HostManagerHostViewer.tsx"
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
import {Separator} from "@/components/ui/separator.tsx";
import {SSHManagerHostEditor} from "@/apps/SSH/Manager/SSHManagerHostEditor.tsx";
import {HostManagerHostEditor} from "@/ui/Apps/Host Manager/HostManagerHostEditor.tsx";
import {useSidebar} from "@/components/ui/sidebar.tsx";
interface ConfigEditorProps {
interface HostManagerProps {
onSelectView: (view: string) => void;
isTopbarOpen?: boolean;
}
interface SSHHost {
@@ -25,16 +26,17 @@ interface SSHHost {
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableConfigEditor: boolean;
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
updatedAt: string;
}
export function SSHManager({onSelectView}: ConfigEditorProps): React.ReactElement {
export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): React.ReactElement {
const [activeTab, setActiveTab] = useState("host_viewer");
const [editingHost, setEditingHost] = useState<SSHHost | null>(null);
const {state: sidebarState} = useSidebar();
const handleEditHost = (host: SSHHost) => {
setEditingHost(host);
@@ -53,33 +55,39 @@ export function SSHManager({onSelectView}: ConfigEditorProps): React.ReactElemen
}
};
const topMarginPx = isTopbarOpen ? 74 : 26;
const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8;
const bottomMarginPx = 8;
return (
<div>
<SSHManagerSidebar
onSelectView={onSelectView}
/>
<div className="flex w-screen h-screen overflow-hidden">
<div className="w-[256px]"/>
<div className="w-full">
<div
className="flex-1 bg-[#18181b] m-[35px] text-white p-4 rounded-md w-[1200px] border h-[calc(100vh-70px)] flex flex-col min-h-0">
className="bg-[#18181b] text-white p-4 pt-0 rounded-lg border-2 border-[#303032] flex flex-col min-h-0 overflow-hidden"
style={{
marginLeft: leftMarginPx,
marginRight: 17,
marginTop: topMarginPx,
marginBottom: bottomMarginPx,
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`
}}
>
<Tabs value={activeTab} onValueChange={handleTabChange}
className="flex-1 flex flex-col h-full min-h-0">
<TabsList>
<TabsList className="bg-[#18181b] border-2 border-[#303032] mt-1.5">
<TabsTrigger value="host_viewer">Host Viewer</TabsTrigger>
<TabsTrigger value="add_host">
{editingHost ? "Edit Host" : "Add Host"}
</TabsTrigger>
</TabsList>
<TabsContent value="host_viewer" className="flex-1 flex flex-col h-full min-h-0">
<Separator className="p-0.25 mt-1 mb-1"/>
<SSHManagerHostViewer onEditHost={handleEditHost}/>
<Separator className="p-0.25 -mt-0.5 mb-1"/>
<HostManagerHostViewer onEditHost={handleEditHost}/>
</TabsContent>
<TabsContent value="add_host" className="flex-1 flex flex-col h-full min-h-0">
<Separator className="p-0.25 mt-1 mb-1"/>
<Separator className="p-0.25 -mt-0.5 mb-1"/>
<div className="flex flex-col h-full min-h-0">
<SSHManagerHostEditor
<HostManagerHostEditor
editingHost={editingHost}
onFormSubmit={handleFormSubmit}
/>

View File

@@ -19,7 +19,7 @@ import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx
import React, {useEffect, useRef, useState} from "react";
import {Switch} from "@/components/ui/switch.tsx";
import {Alert, AlertDescription} from "@/components/ui/alert.tsx";
import {createSSHHost, updateSSHHost, getSSHHosts} from '@/apps/SSH/ssh-axios';
import {createSSHHost, updateSSHHost, getSSHHosts} from '@/ui/main-axios.ts';
interface SSHHost {
id: number;
@@ -37,7 +37,7 @@ interface SSHHost {
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableConfigEditor: boolean;
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
@@ -49,7 +49,7 @@ interface SSHManagerHostEditorProps {
onFormSubmit?: () => void;
}
export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHostEditorProps) {
export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHostEditorProps) {
const [hosts, setHosts] = useState<SSHHost[]>([]);
const [folders, setFolders] = useState<string[]>([]);
const [sshConfigurations, setSshConfigurations] = useState<string[]>([]);
@@ -120,7 +120,7 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
retryInterval: z.coerce.number().min(1).max(3600).default(10),
autoStart: z.boolean().default(false),
})).default([]),
enableConfigEditor: z.boolean().default(true),
enableFileManager: z.boolean().default(true),
defaultPath: z.string().optional(),
}).superRefine((data, ctx) => {
if (data.authType === 'password') {
@@ -178,7 +178,7 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
keyType: "auto",
enableTerminal: editingHost?.enableTerminal !== false,
enableTunnel: editingHost?.enableTunnel !== false,
enableConfigEditor: editingHost?.enableConfigEditor !== false,
enableFileManager: editingHost?.enableFileManager !== false,
defaultPath: editingHost?.defaultPath || "/",
tunnelConnections: editingHost?.tunnelConnections || [],
}
@@ -205,7 +205,7 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
keyType: (editingHost.keyType as any) || "auto",
enableTerminal: editingHost.enableTerminal !== false,
enableTunnel: editingHost.enableTunnel !== false,
enableConfigEditor: editingHost.enableConfigEditor !== false,
enableFileManager: editingHost.enableFileManager !== false,
defaultPath: editingHost.defaultPath || "/",
tunnelConnections: editingHost.tunnelConnections || [],
});
@@ -227,7 +227,7 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
keyType: "auto",
enableTerminal: true,
enableTunnel: true,
enableConfigEditor: true,
enableFileManager: true,
defaultPath: "/",
tunnelConnections: [],
});
@@ -251,6 +251,8 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
if (onFormSubmit) {
onFormSubmit();
}
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
} catch (error) {
alert('Failed to save host. Please try again.');
}
@@ -388,15 +390,15 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
<div className="flex-1 flex flex-col h-full min-h-0 w-full">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0 h-full">
<ScrollArea className="flex-1 min-h-0 w-full my-1">
<ScrollArea className="flex-1 min-h-0 w-full my-1 pb-2">
<Tabs defaultValue="general" className="w-full">
<TabsList>
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="terminal">Terminal</TabsTrigger>
<TabsTrigger value="tunnel">Tunnel</TabsTrigger>
<TabsTrigger value="config_editor">Config Editor</TabsTrigger>
<TabsTrigger value="file_manager">File Manager</TabsTrigger>
</TabsList>
<TabsContent value="general">
<TabsContent value="general" className="pt-2">
<FormLabel className="mb-3 font-bold">Connection Details</FormLabel>
<div className="grid grid-cols-12 gap-4">
<FormField
@@ -580,11 +582,11 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
<FormField
control={form.control}
name="password"
render={({field}) => (
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input placeholder="password" {...field} />
<Input type="password" placeholder="password" {...field} />
</FormControl>
</FormItem>
)}
@@ -809,7 +811,9 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
render={({field: sourcePortField}) => (
<FormItem className="col-span-4">
<FormLabel>Source Port
(Local)</FormLabel>
(Source refers to the Current
Connection Details in the
General tab)</FormLabel>
<FormControl>
<Input
placeholder="22" {...sourcePortField} />
@@ -984,13 +988,13 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
</>
)}
</TabsContent>
<TabsContent value="config_editor">
<TabsContent value="file_manager">
<FormField
control={form.control}
name="enableConfigEditor"
name="enableFileManager"
render={({field}) => (
<FormItem>
<FormLabel>Enable Config Editor</FormLabel>
<FormLabel>Enable File Manager</FormLabel>
<FormControl>
<Switch
checked={field.value}
@@ -998,13 +1002,13 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
/>
</FormControl>
<FormDescription>
Enable/disable host visibility in Config Editor tab.
Enable/disable host visibility in File Manager tab.
</FormDescription>
</FormItem>
)}
/>
{form.watch('enableConfigEditor') && (
{form.watch('enableFileManager') && (
<div className="mt-4">
<FormField
control={form.control}
@@ -1016,7 +1020,7 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
<Input placeholder="/home" {...field} />
</FormControl>
<FormDescription>Set default directory shown when connected via
Config Editor</FormDescription>
File Manager</FormDescription>
</FormItem>
)}
/>
@@ -1025,9 +1029,18 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
</TabsContent>
</Tabs>
</ScrollArea>
<footer className="shrink-0 w-full">
<Separator className="p-0.25 mt-1 mb-3"/>
<Button type="submit" variant="outline">{editingHost ? "Update Host" : "Add Host"}</Button>
<footer className="shrink-0 w-full pb-0">
<Separator className="p-0.25"/>
<Button
className=""
type="submit"
variant="outline"
style={{
transform: 'translateY(8px)'
}}
>
{editingHost ? "Update Host" : "Add Host"}
</Button>
</footer>
</form>
</Form>

View File

@@ -5,8 +5,23 @@ import {Badge} from "@/components/ui/badge";
import {ScrollArea} from "@/components/ui/scroll-area";
import {Input} from "@/components/ui/input";
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion";
import {getSSHHosts, deleteSSHHost} from "@/apps/SSH/ssh-axios";
import {Edit, Trash2, Server, Folder, Tag, Pin, Terminal, Network, FileEdit, Search} from "lucide-react";
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
import {getSSHHosts, deleteSSHHost, bulkImportSSHHosts} from "@/ui/main-axios.ts";
import {
Edit,
Trash2,
Server,
Folder,
Tag,
Pin,
Terminal,
Network,
FileEdit,
Search,
Upload,
Info
} from "lucide-react";
import {Separator} from "@/components/ui/separator.tsx";
interface SSHHost {
id: number;
@@ -20,7 +35,7 @@ interface SSHHost {
authType: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableConfigEditor: boolean;
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
@@ -31,11 +46,12 @@ interface SSHManagerHostViewerProps {
onEditHost?: (host: SSHHost) => void;
}
export function SSHManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
const [hosts, setHosts] = useState<SSHHost[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [importing, setImporting] = useState(false);
useEffect(() => {
fetchHosts();
@@ -59,6 +75,7 @@ export function SSHManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
try {
await deleteSSHHost(hostId);
await fetchHosts();
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
} catch (err) {
alert('Failed to delete host');
}
@@ -71,6 +88,48 @@ export function SSHManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
}
};
const handleJsonImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
try {
setImporting(true);
const text = await file.text();
const data = JSON.parse(text);
if (!Array.isArray(data.hosts) && !Array.isArray(data)) {
throw new Error('JSON must contain a "hosts" array or be an array of hosts');
}
const hostsArray = Array.isArray(data.hosts) ? data.hosts : data;
if (hostsArray.length === 0) {
throw new Error('No hosts found in JSON file');
}
if (hostsArray.length > 100) {
throw new Error('Maximum 100 hosts allowed per import');
}
const result = await bulkImportSSHHosts(hostsArray);
if (result.success > 0) {
alert(`Import completed: ${result.success} successful, ${result.failed} failed${result.errors.length > 0 ? '\n\nErrors:\n' + result.errors.join('\n') : ''}`);
await fetchHosts();
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
} else {
alert(`Import failed: ${result.errors.join('\n')}`);
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to import JSON file';
alert(`Import error: ${errorMessage}`);
} finally {
setImporting(false);
event.target.value = '';
}
};
const filteredAndSortedHosts = useMemo(() => {
let filtered = hosts;
@@ -172,11 +231,122 @@ export function SSHManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
{filteredAndSortedHosts.length} hosts
</p>
</div>
<Button onClick={fetchHosts} variant="outline" size="sm">
Refresh
</Button>
<div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
className="relative"
onClick={() => document.getElementById('json-import-input')?.click()}
disabled={importing}
>
{importing ? 'Importing...' : 'Import JSON'}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom"
className="max-w-sm bg-popover text-popover-foreground border border-border shadow-lg">
<div className="space-y-2">
<p className="font-semibold text-sm">Import SSH Hosts from JSON</p>
<p className="text-xs text-muted-foreground">
Upload a JSON file to bulk import multiple SSH hosts (max 100).
</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
variant="outline"
size="sm"
onClick={() => {
const sampleData = {
hosts: [
{
name: "Web Server - Production",
ip: "192.168.1.100",
port: 22,
username: "admin",
authType: "password",
password: "your_secure_password_here",
folder: "Production",
tags: ["web", "production", "nginx"],
pin: true,
enableTerminal: true,
enableTunnel: false,
enableFileManager: true,
defaultPath: "/var/www"
},
{
name: "Database Server",
ip: "192.168.1.101",
port: 22,
username: "dbadmin",
authType: "key",
key: "-----BEGIN OPENSSH PRIVATE KEY-----\nYour SSH private key content here\n-----END OPENSSH PRIVATE KEY-----",
keyPassword: "optional_key_passphrase",
keyType: "ssh-ed25519",
folder: "Production",
tags: ["database", "production", "postgresql"],
pin: false,
enableTerminal: true,
enableTunnel: true,
enableFileManager: false,
tunnelConnections: [
{
sourcePort: 5432,
endpointPort: 5432,
endpointHost: "Web Server - Production",
maxRetries: 3,
retryInterval: 10,
autoStart: true
}
]
}
]
};
const blob = new Blob([JSON.stringify(sampleData, null, 2)], {type: 'application/json'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'sample-ssh-hosts.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}}
>
Download Sample
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
window.open('https://docs.termix.site/json-import', '_blank');
}}
>
Format Guide
</Button>
<div className="w-px h-6 bg-border mx-2"/>
<Button onClick={fetchHosts} variant="outline" size="sm">
Refresh
</Button>
</div>
</div>
<input
id="json-import-input"
type="file"
accept=".json"
onChange={handleJsonImport}
style={{display: 'none'}}
/>
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
<Input
@@ -257,7 +427,7 @@ export function SSHManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
{host.tags && host.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{host.tags.slice(0, 6).map((tag, index) => (
<Badge key={index} variant="secondary"
<Badge key={index} variant="outline"
className="text-xs px-1 py-0">
<Tag className="h-2 w-2 mr-0.5"/>
{tag}
@@ -289,10 +459,10 @@ export function SSHManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
)}
</Badge>
)}
{host.enableConfigEditor && (
{host.enableFileManager && (
<Badge variant="outline" className="text-xs px-1 py-0">
<FileEdit className="h-2 w-2 mr-0.5"/>
Config
File Manager
</Badge>
)}
</div>

View File

@@ -0,0 +1,287 @@
import React from "react";
import {useSidebar} from "@/components/ui/sidebar";
import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status";
import {Separator} from "@/components/ui/separator.tsx";
import {Button} from "@/components/ui/button.tsx";
import {Progress} from "@/components/ui/progress"
import {Cpu, HardDrive, MemoryStick} from "lucide-react";
import {Tunnel} from "@/ui/Apps/Tunnel/Tunnel.tsx";
import {getServerStatusById, getServerMetricsById, type ServerMetrics} from "@/ui/main-axios.ts";
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
interface ServerProps {
hostConfig?: any;
title?: string;
isVisible?: boolean;
isTopbarOpen?: boolean;
embedded?: boolean;
}
export function Server({
hostConfig,
title,
isVisible = true,
isTopbarOpen = true,
embedded = false
}: ServerProps): React.ReactElement {
const {state: sidebarState} = useSidebar();
const {addTab, tabs} = useTabs() as any;
const [serverStatus, setServerStatus] = React.useState<'online' | 'offline'>('offline');
const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null);
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
React.useEffect(() => {
setCurrentHostConfig(hostConfig);
}, [hostConfig]);
React.useEffect(() => {
const fetchLatestHostConfig = async () => {
if (hostConfig?.id) {
try {
const {getSSHHosts} = await import('@/ui/main-axios.ts');
const hosts = await getSSHHosts();
const updatedHost = hosts.find(h => h.id === hostConfig.id);
if (updatedHost) {
setCurrentHostConfig(updatedHost);
}
} catch (error) {
}
}
};
fetchLatestHostConfig();
const handleHostsChanged = async () => {
if (hostConfig?.id) {
try {
const {getSSHHosts} = await import('@/ui/main-axios.ts');
const hosts = await getSSHHosts();
const updatedHost = hosts.find(h => h.id === hostConfig.id);
if (updatedHost) {
setCurrentHostConfig(updatedHost);
}
} catch (error) {
}
}
};
window.addEventListener('ssh-hosts:changed', handleHostsChanged);
return () => window.removeEventListener('ssh-hosts:changed', handleHostsChanged);
}, [hostConfig?.id]);
React.useEffect(() => {
let cancelled = false;
let intervalId: number | undefined;
const fetchStatus = async () => {
try {
const res = await getServerStatusById(currentHostConfig?.id);
if (!cancelled) {
setServerStatus(res?.status === 'online' ? 'online' : 'offline');
}
} catch {
if (!cancelled) setServerStatus('offline');
}
};
const fetchMetrics = async () => {
if (!currentHostConfig?.id) return;
try {
const data = await getServerMetricsById(currentHostConfig.id);
if (!cancelled) setMetrics(data);
} catch {
if (!cancelled) setMetrics(null);
}
};
if (currentHostConfig?.id && isVisible) {
fetchStatus();
fetchMetrics();
intervalId = window.setInterval(() => {
if (isVisible) {
fetchStatus();
fetchMetrics();
}
}, 30000);
}
return () => {
cancelled = true;
if (intervalId) window.clearInterval(intervalId);
};
}, [currentHostConfig?.id, isVisible]);
const topMarginPx = isTopbarOpen ? 74 : 16;
const leftMarginPx = sidebarState === 'collapsed' ? 16 : 8;
const bottomMarginPx = 8;
const isFileManagerAlreadyOpen = React.useMemo(() => {
if (!currentHostConfig) return false;
return tabs.some((tab: any) =>
tab.type === 'file_manager' &&
tab.hostConfig?.id === currentHostConfig.id
);
}, [tabs, currentHostConfig]);
const wrapperStyle: React.CSSProperties = embedded
? {opacity: isVisible ? 1 : 0, height: '100%', width: '100%'}
: {
opacity: isVisible ? 1 : 0,
marginLeft: leftMarginPx,
marginRight: 17,
marginTop: topMarginPx,
marginBottom: bottomMarginPx,
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
};
const containerClass = embedded
? "h-full w-full text-white overflow-hidden bg-transparent"
: "bg-[#18181b] text-white rounded-lg border-2 border-[#303032] overflow-hidden";
return (
<div style={wrapperStyle} className={containerClass}>
<div className="h-full w-full flex flex-col">
{/* Top Header */}
<div className="flex items-center justify-between px-3 pt-2 pb-2">
<div className="flex items-center gap-4">
<h1 className="font-bold text-lg">
{currentHostConfig?.folder} / {title}
</h1>
<Status status={serverStatus} className="!bg-transparent !p-0.75 flex-shrink-0">
<StatusIndicator/>
</Status>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={async () => {
if (currentHostConfig?.id) {
try {
const res = await getServerStatusById(currentHostConfig.id);
setServerStatus(res?.status === 'online' ? 'online' : 'offline');
const data = await getServerMetricsById(currentHostConfig.id);
setMetrics(data);
} catch {
setServerStatus('offline');
setMetrics(null);
}
}
}}
title="Refresh status and metrics"
>
Refresh Status
</Button>
{currentHostConfig?.enableFileManager && (
<Button
variant="outline"
className="font-semibold"
disabled={isFileManagerAlreadyOpen}
title={isFileManagerAlreadyOpen ? "File Manager already open for this host" : "Open File Manager"}
onClick={() => {
if (!currentHostConfig || isFileManagerAlreadyOpen) return;
const titleBase = currentHostConfig?.name && currentHostConfig.name.trim() !== ''
? currentHostConfig.name.trim()
: `${currentHostConfig.username}@${currentHostConfig.ip}`;
addTab({
type: 'file_manager',
title: titleBase,
hostConfig: currentHostConfig,
});
}}
>
File Manager
</Button>
)}
</div>
</div>
<Separator className="p-0.25 w-full"/>
{/* Stats */}
<div className="rounded-lg border-2 border-[#303032] m-3 bg-[#0e0e10] flex flex-row items-stretch">
{/* CPU */}
<div className="flex-1 min-w-0 px-2 py-2">
<h1 className="font-bold xt-lg flex flex-row gap-2 mb-2">
<Cpu/>
{(() => {
const pct = metrics?.cpu?.percent;
const cores = metrics?.cpu?.cores;
const la = metrics?.cpu?.load;
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
const coresText = (typeof cores === 'number') ? `${cores} CPU(s)` : 'N/A CPU(s)';
const laText = (la && la.length === 3)
? `Avg: ${la[0].toFixed(2)}, ${la[1].toFixed(2)}, ${la[2].toFixed(2)}`
: 'Avg: N/A';
return `CPU Usage - ${pctText} of ${coresText} (${laText})`;
})()}
</h1>
<Progress value={typeof metrics?.cpu?.percent === 'number' ? metrics!.cpu!.percent! : 0}/>
</div>
<Separator className="p-0.5 self-stretch" orientation="vertical"/>
{/* Memory */}
<div className="flex-1 min-w-0 px-2 py-2">
<h1 className="font-bold xt-lg flex flex-row gap-2 mb-2">
<MemoryStick/>
{(() => {
const pct = metrics?.memory?.percent;
const used = metrics?.memory?.usedGiB;
const total = metrics?.memory?.totalGiB;
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
const usedText = (typeof used === 'number') ? `${used} GiB` : 'N/A';
const totalText = (typeof total === 'number') ? `${total} GiB` : 'N/A';
return `Memory Usage - ${pctText} (${usedText} of ${totalText})`;
})()}
</h1>
<Progress value={typeof metrics?.memory?.percent === 'number' ? metrics!.memory!.percent! : 0}/>
</div>
<Separator className="p-0.5 self-stretch" orientation="vertical"/>
{/* Root Storage */}
<div className="flex-1 min-w-0 px-2 py-2">
<h1 className="font-bold xt-lg flex flex-row gap-2 mb-2">
<HardDrive/>
{(() => {
const pct = metrics?.disk?.percent;
const used = metrics?.disk?.usedHuman;
const total = metrics?.disk?.totalHuman;
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
const usedText = used ?? 'N/A';
const totalText = total ?? 'N/A';
return `Root Storage Space - ${pctText} (${usedText} of ${totalText})`;
})()}
</h1>
<Progress value={typeof metrics?.disk?.percent === 'number' ? metrics!.disk!.percent! : 0}/>
</div>
</div>
{/* SSH Tunnels */}
{(currentHostConfig?.tunnelConnections && currentHostConfig.tunnelConnections.length > 0) && (
<div
className="rounded-lg border-2 border-[#303032] m-3 bg-[#0e0e10] h-[360px] overflow-hidden flex flex-col min-h-0">
<Tunnel
filterHostKey={(currentHostConfig?.name && currentHostConfig.name.trim() !== '') ? currentHostConfig.name : `${currentHostConfig?.username}@${currentHostConfig?.ip}`}/>
</div>
)}
<p className="px-4 pt-2 pb-2 text-sm text-gray-500">
Have ideas for what should come next for server management? Share them on{" "}
<a
href="https://github.com/LukeGus/Termix/issues/new"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
GitHub
</a>
!
</p>
</div>
</div>
);
}

View File

@@ -13,7 +13,7 @@ interface SSHTerminalProps {
splitScreen?: boolean;
}
export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
{hostConfig, isVisible, splitScreen = false},
ref
) {
@@ -24,6 +24,42 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
const wasDisconnectedBySSH = useRef(false);
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const [visible, setVisible] = useState(false);
const isVisibleRef = useRef<boolean>(false);
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null);
const notifyTimerRef = useRef<NodeJS.Timeout | null>(null);
const DEBOUNCE_MS = 140;
useEffect(() => {
isVisibleRef.current = isVisible;
}, [isVisible]);
function hardRefresh() {
try {
if (terminal && typeof (terminal as any).refresh === 'function') {
(terminal as any).refresh(0, terminal.rows - 1);
}
} catch (_) {
}
}
function scheduleNotify(cols: number, rows: number) {
if (!(cols > 0 && rows > 0)) return;
pendingSizeRef.current = {cols, rows};
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
notifyTimerRef.current = setTimeout(() => {
const next = pendingSizeRef.current;
const last = lastSentSizeRef.current;
if (!next) return;
if (last && last.cols === next.cols && last.rows === next.rows) return;
if (webSocketRef.current?.readyState === WebSocket.OPEN) {
webSocketRef.current.send(JSON.stringify({type: 'resize', data: next}));
lastSentSizeRef.current = next;
}
}, DEBOUNCE_MS);
}
useImperativeHandle(ref, () => ({
disconnect: () => {
@@ -35,13 +71,27 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
},
fit: () => {
fitAddonRef.current?.fit();
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
hardRefresh();
},
sendInput: (data: string) => {
if (webSocketRef.current?.readyState === 1) {
webSocketRef.current.send(JSON.stringify({type: 'input', data}));
}
}
}), []);
},
notifyResize: () => {
try {
const cols = terminal?.cols ?? undefined;
const rows = terminal?.rows ?? undefined;
if (typeof cols === 'number' && typeof rows === 'number') {
scheduleNotify(cols, rows);
hardRefresh();
}
} catch (_) {
}
},
refresh: () => hardRefresh(),
}), [terminal]);
useEffect(() => {
window.addEventListener('resize', handleWindowResize);
@@ -49,7 +99,10 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
}, []);
function handleWindowResize() {
if (!isVisibleRef.current) return;
fitAddonRef.current?.fit();
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
hardRefresh();
}
function getCookie(name: string) {
@@ -63,6 +116,50 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
return getCookie("rightClickCopyPaste") === "true"
}
function setupWebSocketListeners(ws: WebSocket, cols: number, rows: number) {
ws.addEventListener('open', () => {
ws.send(JSON.stringify({type: 'connectToHost', data: {cols, rows, hostConfig}}));
terminal.onData((data) => {
ws.send(JSON.stringify({type: 'input', data}));
});
pingIntervalRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({type: 'ping'}));
}
}, 30000);
});
ws.addEventListener('message', (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === 'data') terminal.write(msg.data);
else if (msg.type === 'error') terminal.writeln(`\r\n[ERROR] ${msg.message}`);
else if (msg.type === 'connected') {
} else if (msg.type === 'disconnected') {
wasDisconnectedBySSH.current = true;
terminal.writeln(`\r\n[${msg.message || 'Disconnected'}]`);
}
} catch (error) {
}
});
ws.addEventListener('close', () => {
if (!wasDisconnectedBySSH.current) {
terminal.writeln('\r\n[Connection closed]');
}
});
ws.addEventListener('error', () => {
terminal.writeln('\r\n[Connection error]');
});
}
async function writeTextToClipboard(text: string): Promise<void> {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
@@ -104,10 +201,7 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
scrollback: 10000,
fontSize: 14,
fontFamily: '"JetBrains Mono Nerd Font", "MesloLGS NF", "FiraCode Nerd Font", "Cascadia Code", "JetBrains Mono", Consolas, "Courier New", monospace',
theme: {
background: '#09090b',
foreground: '#f7f7f7',
},
theme: {background: '#18181b', foreground: '#f7f7f7'},
allowTransparency: true,
convertEol: true,
windowsMode: false,
@@ -145,90 +239,61 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
}
} else {
const pasteText = await readTextFromClipboard();
if (pasteText) {
terminal.paste(pasteText);
}
if (pasteText) terminal.paste(pasteText);
}
} catch (_) {
}
};
if (element) {
element.addEventListener('contextmenu', handleContextMenu);
}
element?.addEventListener('contextmenu', handleContextMenu);
const resizeObserver = new ResizeObserver(() => {
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
resizeTimeout.current = setTimeout(() => {
if (!isVisibleRef.current) return;
fitAddonRef.current?.fit();
const cols = terminal.cols;
const rows = terminal.rows;
if (webSocketRef.current?.readyState === WebSocket.OPEN) {
webSocketRef.current.send(JSON.stringify({type: 'resize', data: {cols, rows}}));
}
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
hardRefresh();
}, 100);
});
resizeObserver.observe(xtermRef.current);
setTimeout(() => {
fitAddon.fit();
setVisible(true);
const cols = terminal.cols;
const rows = terminal.rows;
const wsUrl = window.location.hostname === 'localhost'
? 'ws://localhost:8082'
: `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`;
const ws = new WebSocket(wsUrl);
webSocketRef.current = ws;
wasDisconnectedBySSH.current = false;
ws.addEventListener('open', () => {
ws.send(JSON.stringify({type: 'connectToHost', data: {cols, rows, hostConfig}}));
terminal.onData((data) => {
ws.send(JSON.stringify({type: 'input', data}));
});
pingIntervalRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({type: 'ping'}));
const readyFonts = (document as any).fonts?.ready instanceof Promise ? (document as any).fonts.ready : Promise.resolve();
readyFonts.then(() => {
setTimeout(() => {
fitAddon.fit();
setTimeout(() => {
fitAddon.fit();
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
hardRefresh();
setVisible(true);
if (terminal && !splitScreen) {
terminal.focus();
}
}, 30000);
});
}, 0);
ws.addEventListener('message', (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === 'data') {
terminal.write(msg.data);
} else if (msg.type === 'error') {
terminal.writeln(`\r\n[ERROR] ${msg.message}`);
} else if (msg.type === 'connected') {
} else if (msg.type === 'disconnected') {
wasDisconnectedBySSH.current = true;
terminal.writeln(`\r\n[${msg.message || 'Disconnected'}]`);
}
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
});
const cols = terminal.cols;
const rows = terminal.rows;
ws.addEventListener('close', () => {
if (!wasDisconnectedBySSH.current) {
terminal.writeln('\r\n[Connection closed]');
}
});
const isDev = process.env.NODE_ENV === 'development' &&
(window.location.port === '3000' || window.location.port === '5173' || window.location.port === '');
ws.addEventListener('error', () => {
terminal.writeln('\r\n[Connection error]');
});
}, 300);
const wsUrl = isDev
? 'ws://localhost:8082'
: `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`;
const ws = new WebSocket(wsUrl);
webSocketRef.current = ws;
wasDisconnectedBySSH.current = false;
setupWebSocketListeners(ws, cols, rows);
}, 300);
});
return () => {
resizeObserver.disconnect();
if (element) {
element.removeEventListener('contextmenu', handleContextMenu);
}
element?.removeEventListener('contextmenu', handleContextMenu);
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
@@ -240,22 +305,44 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
useEffect(() => {
if (isVisible && fitAddonRef.current) {
fitAddonRef.current.fit();
setTimeout(() => {
fitAddonRef.current?.fit();
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
hardRefresh();
if (terminal && !splitScreen) {
terminal.focus();
}
}, 0);
if (terminal && !splitScreen) {
setTimeout(() => {
terminal.focus();
}, 100);
}
}
}, [isVisible]);
}, [isVisible, splitScreen, terminal]);
useEffect(() => {
if (!fitAddonRef.current) return;
setTimeout(() => {
fitAddonRef.current?.fit();
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
hardRefresh();
if (terminal && !splitScreen && isVisible) {
terminal.focus();
}
}, 0);
}, [splitScreen, isVisible, terminal]);
return (
<div
ref={xtermRef}
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
marginLeft: 2,
opacity: visible && isVisible ? 1 : 0,
overflow: 'hidden',
<div
ref={xtermRef}
className="h-full w-full m-1"
style={{opacity: visible && isVisible ? 1 : 0, overflow: 'hidden'}}
onClick={() => {
if (terminal && !splitScreen) {
terminal.focus();
}
}}
/>
);

View File

@@ -1,11 +1,6 @@
import React, {useState, useEffect, useCallback} from "react";
import {SSHTunnelSidebar} from "@/apps/SSH/Tunnel/SSHTunnelSidebar.tsx";
import {SSHTunnelViewer} from "@/apps/SSH/Tunnel/SSHTunnelViewer.tsx";
import {getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel} from "@/apps/SSH/ssh-axios";
interface ConfigEditorProps {
onSelectView: (view: string) => void;
}
import {TunnelViewer} from "@/ui/Apps/Tunnel/TunnelViewer.tsx";
import {getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel} from "@/ui/main-axios.ts";
interface TunnelConnection {
sourcePort: number;
@@ -32,7 +27,7 @@ interface SSHHost {
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableConfigEditor: boolean;
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: TunnelConnection[];
createdAt: string;
@@ -49,31 +44,89 @@ interface TunnelStatus {
retryExhausted?: boolean;
}
export function SSHTunnel({onSelectView}: ConfigEditorProps): React.ReactElement {
const [hosts, setHosts] = useState<SSHHost[]>([]);
interface SSHTunnelProps {
filterHostKey?: string;
}
export function Tunnel({filterHostKey}: SSHTunnelProps): React.ReactElement {
const [allHosts, setAllHosts] = useState<SSHHost[]>([]);
const [visibleHosts, setVisibleHosts] = useState<SSHHost[]>([]);
const [tunnelStatuses, setTunnelStatuses] = useState<Record<string, TunnelStatus>>({});
const [tunnelActions, setTunnelActions] = useState<Record<string, boolean>>({});
const fetchHosts = useCallback(async () => {
try {
const hostsData = await getSSHHosts();
setHosts(hostsData);
} catch (err) {
const prevVisibleHostRef = React.useRef<SSHHost | null>(null);
const haveTunnelConnectionsChanged = (a: TunnelConnection[] = [], b: TunnelConnection[] = []): boolean => {
if (a.length !== b.length) return true;
for (let i = 0; i < a.length; i++) {
const x = a[i];
const y = b[i];
if (
x.sourcePort !== y.sourcePort ||
x.endpointPort !== y.endpointPort ||
x.endpointHost !== y.endpointHost ||
x.maxRetries !== y.maxRetries ||
x.retryInterval !== y.retryInterval ||
x.autoStart !== y.autoStart
) {
return true;
}
}
}, []);
return false;
};
const fetchHosts = useCallback(async () => {
const hostsData = await getSSHHosts();
setAllHosts(hostsData);
const nextVisible = filterHostKey
? hostsData.filter(h => {
const key = (h.name && h.name.trim() !== '') ? h.name : `${h.username}@${h.ip}`;
return key === filterHostKey;
})
: hostsData;
const prev = prevVisibleHostRef.current;
const curr = nextVisible[0] ?? null;
let changed = false;
if (!prev && curr) changed = true;
else if (prev && !curr) changed = true;
else if (prev && curr) {
if (
prev.id !== curr.id ||
prev.name !== curr.name ||
prev.ip !== curr.ip ||
prev.port !== curr.port ||
prev.username !== curr.username ||
haveTunnelConnectionsChanged(prev.tunnelConnections, curr.tunnelConnections)
) {
changed = true;
}
}
if (changed) {
setVisibleHosts(nextVisible);
prevVisibleHostRef.current = curr;
}
}, [filterHostKey]);
const fetchTunnelStatuses = useCallback(async () => {
try {
const statusData = await getTunnelStatuses();
setTunnelStatuses(statusData);
} catch (err) {
}
const statusData = await getTunnelStatuses();
setTunnelStatuses(statusData);
}, []);
useEffect(() => {
fetchHosts();
const interval = setInterval(fetchHosts, 10000);
return () => clearInterval(interval);
const interval = setInterval(fetchHosts, 5000);
const handleHostsChanged = () => {
fetchHosts();
};
window.addEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
return () => {
clearInterval(interval);
window.removeEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
};
}, [fetchHosts]);
useEffect(() => {
@@ -90,7 +143,7 @@ export function SSHTunnel({onSelectView}: ConfigEditorProps): React.ReactElement
try {
if (action === 'connect') {
const endpointHost = hosts.find(h =>
const endpointHost = allHosts.find(h =>
h.name === tunnel.endpointHost ||
`${h.username}@${h.ip}` === tunnel.endpointHost
);
@@ -141,20 +194,11 @@ export function SSHTunnel({onSelectView}: ConfigEditorProps): React.ReactElement
};
return (
<div className="flex h-screen w-full">
<div className="w-64 flex-shrink-0">
<SSHTunnelSidebar
onSelectView={onSelectView}
/>
</div>
<div className="flex-1 overflow-auto">
<SSHTunnelViewer
hosts={hosts}
tunnelStatuses={tunnelStatuses}
tunnelActions={tunnelActions}
onTunnelAction={handleTunnelAction}
/>
</div>
</div>
<TunnelViewer
hosts={visibleHosts}
tunnelStatuses={tunnelStatuses}
tunnelActions={tunnelActions}
onTunnelAction={handleTunnelAction}
/>
);
}

View File

@@ -53,7 +53,7 @@ interface SSHHost {
authType: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableConfigEditor: boolean;
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: TunnelConnection[];
createdAt: string;
@@ -75,14 +75,18 @@ interface SSHTunnelObjectProps {
tunnelStatuses: Record<string, TunnelStatus>;
tunnelActions: Record<string, boolean>;
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>;
compact?: boolean;
bare?: boolean;
}
export function SSHTunnelObject({
host,
tunnelStatuses,
tunnelActions,
onTunnelAction
}: SSHTunnelObjectProps): React.ReactElement {
export function TunnelObject({
host,
tunnelStatuses,
tunnelActions,
onTunnelAction,
compact = false,
bare = false
}: SSHTunnelObjectProps): React.ReactElement {
const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => {
const tunnel = host.tunnelConnections[tunnelIndex];
@@ -161,26 +165,166 @@ export function SSHTunnelObject({
}
};
if (bare) {
return (
<div className="w-full min-w-0">
<div className="space-y-3">
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
<div className="space-y-3">
{host.tunnelConnections.map((tunnel, tunnelIndex) => {
const status = getTunnelStatus(tunnelIndex);
const statusDisplay = getTunnelStatusDisplay(status);
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
const isActionLoading = tunnelActions[tunnelName];
const statusValue = status?.status?.toUpperCase() || 'DISCONNECTED';
const isConnected = statusValue === 'CONNECTED';
const isConnecting = statusValue === 'CONNECTING';
const isDisconnecting = statusValue === 'DISCONNECTING';
const isRetrying = statusValue === 'RETRYING';
const isWaiting = statusValue === 'WAITING';
return (
<div key={tunnelIndex}
className={`border rounded-lg p-3 min-w-0 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}>
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2 flex-1 min-w-0">
<span className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}>
{statusDisplay.icon}
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium break-words">
Port {tunnel.sourcePort} {tunnel.endpointHost}:{tunnel.endpointPort}
</div>
<div className={`text-xs ${statusDisplay.color} font-medium`}>
{statusDisplay.text}
</div>
</div>
</div>
<div className="flex flex-col items-end gap-1 flex-shrink-0 min-w-[120px]">
{!isActionLoading ? (
<div className="flex flex-col gap-1">
{isConnected ? (
<>
<Button
size="sm"
variant="outline"
onClick={() => onTunnelAction('disconnect', host, tunnelIndex)}
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
>
<Square className="h-3 w-3 mr-1"/>
Disconnect
</Button>
</>
) : isRetrying || isWaiting ? (
<Button
size="sm"
variant="outline"
onClick={() => onTunnelAction('cancel', host, tunnelIndex)}
className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs"
>
<X className="h-3 w-3 mr-1"/>
Cancel
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={() => onTunnelAction('connect', host, tunnelIndex)}
disabled={isConnecting || isDisconnecting}
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
>
<Play className="h-3 w-3 mr-1"/>
Connect
</Button>
)}
</div>
) : (
<Button
size="sm"
variant="outline"
disabled
className="h-7 px-2 text-muted-foreground border-border text-xs"
>
<Loader2 className="h-3 w-3 mr-1 animate-spin"/>
{isConnected ? 'Disconnecting...' : isRetrying || isWaiting ? 'Canceling...' : 'Connecting...'}
</Button>
)}
</div>
</div>
{(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && (
<div
className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
<div className="font-medium mb-1">Error:</div>
{status.reason}
{status.reason && status.reason.includes('Max retries exhausted') && (
<>
<div
className="mt-2 pt-2 border-t border-red-500/20 dark:border-red-400/20">
Check your Docker logs for the error reason, join the <a
href="https://discord.com/invite/jVQGdvHDrf" target="_blank"
rel="noopener noreferrer"
className="underline text-blue-600 dark:text-blue-400">Discord</a> or
create a <a
href="https://github.com/LukeGus/Termix/issues/new"
target="_blank" rel="noopener noreferrer"
className="underline text-blue-600 dark:text-blue-400">GitHub
issue</a> for help.
</div>
</>
)}
</div>
)}
{(statusValue === 'RETRYING' || statusValue === 'WAITING') && status?.retryCount && status?.maxRetries && (
<div
className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
<div className="font-medium mb-1">
{statusValue === 'WAITING' ? 'Waiting for retry' : 'Retrying connection'}
</div>
<div>
Attempt {status.retryCount} of {status.maxRetries}
{status.nextRetryIn && (
<span> Next retry in {status.nextRetryIn} seconds</span>
)}
</div>
</div>
)}
</div>
);
})}
</div>
) : (
<div className="text-center py-4 text-muted-foreground">
<Network className="h-8 w-8 mx-auto mb-2 opacity-50"/>
<p className="text-sm">No tunnel connections configured</p>
</div>
)}
</div>
</div>
);
}
return (
<Card className="w-full bg-card border-border shadow-sm hover:shadow-md transition-shadow relative group p-0">
<div className="p-4">
{/* Host Header */}
<div className="flex items-center justify-between gap-2 mb-3">
<div className="flex items-center gap-2 flex-1 min-w-0">
{host.pin && <Pin className="h-4 w-4 text-yellow-500 flex-shrink-0"/>}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-card-foreground truncate">
{host.name || `${host.username}@${host.ip}`}
</h3>
<p className="text-xs text-muted-foreground truncate">
{host.ip}:{host.port} {host.username}
</p>
{!compact && (
<div className="flex items-center justify-between gap-2 mb-3">
<div className="flex items-center gap-2 flex-1 min-w-0">
{host.pin && <Pin className="h-4 w-4 text-yellow-500 flex-shrink-0"/>}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-card-foreground truncate">
{host.name || `${host.username}@${host.ip}`}
</h3>
<p className="text-xs text-muted-foreground truncate">
{host.ip}:{host.port} {host.username}
</p>
</div>
</div>
</div>
</div>
)}
{/* Tags */}
{host.tags && host.tags.length > 0 && (
{!compact && host.tags && host.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-3">
{host.tags.slice(0, 3).map((tag, index) => (
<Badge key={index} variant="secondary" className="text-xs px-1 py-0">
@@ -196,14 +340,15 @@ export function SSHTunnelObject({
</div>
)}
<Separator className="mb-3"/>
{!compact && <Separator className="mb-3"/>}
{/* Tunnel Connections */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-card-foreground flex items-center gap-2">
<Network className="h-4 w-4"/>
Tunnel Connections ({host.tunnelConnections.length})
</h4>
{!compact && (
<h4 className="text-sm font-medium text-card-foreground flex items-center gap-2">
<Network className="h-4 w-4"/>
Tunnel Connections ({host.tunnelConnections.length})
</h4>
)}
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
<div className="space-y-3">
{host.tunnelConnections.map((tunnel, tunnelIndex) => {
@@ -221,7 +366,6 @@ export function SSHTunnelObject({
return (
<div key={tunnelIndex}
className={`border rounded-lg p-3 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}>
{/* Tunnel Header */}
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2 flex-1 min-w-0">
<span className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}>
@@ -237,13 +381,6 @@ export function SSHTunnelObject({
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{tunnel.autoStart && (
<Badge variant="outline" className="text-xs px-2 py-1">
<Zap className="h-3 w-3 mr-1"/>
Auto
</Badge>
)}
{/* Action Buttons */}
{!isActionLoading && (
<div className="flex flex-col gap-1">
{isConnected ? (
@@ -296,7 +433,6 @@ export function SSHTunnelObject({
</div>
</div>
{/* Error/Status Reason */}
{(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && (
<div
className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
@@ -321,7 +457,6 @@ export function SSHTunnelObject({
</div>
)}
{/* Retry Info */}
{(statusValue === 'RETRYING' || statusValue === 'WAITING') && status?.retryCount && status?.maxRetries && (
<div
className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">

View File

@@ -0,0 +1,92 @@
import React from "react";
import {TunnelObject} from "./TunnelObject.tsx";
interface TunnelConnection {
sourcePort: number;
endpointPort: number;
endpointHost: string;
maxRetries: number;
retryInterval: number;
autoStart: boolean;
}
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: TunnelConnection[];
createdAt: string;
updatedAt: string;
}
interface TunnelStatus {
status: string;
reason?: string;
errorType?: string;
retryCount?: number;
maxRetries?: number;
nextRetryIn?: number;
retryExhausted?: boolean;
}
interface SSHTunnelViewerProps {
hosts: SSHHost[];
tunnelStatuses: Record<string, TunnelStatus>;
tunnelActions: Record<string, boolean>;
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>;
}
export function TunnelViewer({
hosts = [],
tunnelStatuses = {},
tunnelActions = {},
onTunnelAction
}: SSHTunnelViewerProps): React.ReactElement {
const activeHost: SSHHost | undefined = Array.isArray(hosts) && hosts.length > 0 ? hosts[0] : undefined;
if (!activeHost || !activeHost.tunnelConnections || activeHost.tunnelConnections.length === 0) {
return (
<div className="w-full h-full flex flex-col items-center justify-center text-center p-3">
<h3 className="text-lg font-semibold text-foreground mb-2">No SSH Tunnels</h3>
<p className="text-muted-foreground max-w-md">
Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel
connections.
</p>
</div>
);
}
return (
<div className="w-full h-full flex flex-col overflow-hidden p-3 min-h-0">
<div className="w-full flex-shrink-0 mb-2">
<h1 className="text-xl font-semibold text-foreground">SSH Tunnels</h1>
</div>
<div className="min-h-0 flex-1 overflow-auto pr-1">
<div
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{activeHost.tunnelConnections.map((t, idx) => (
<TunnelObject
key={`tunnel-${activeHost.id}-${t.endpointHost}-${t.sourcePort}-${t.endpointPort}`}
host={{...activeHost, tunnelConnections: [activeHost.tunnelConnections[idx]]}}
tunnelStatuses={tunnelStatuses}
tunnelActions={tunnelActions}
onTunnelAction={(action, _host, _index) => onTunnelAction(action, activeHost, idx)}
compact
bare
/>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,158 @@
import React, {useEffect, useState} from "react";
import {HomepageAuth} from "@/ui/Homepage/HomepageAuth.tsx";
import {HomepageUpdateLog} from "@/ui/Homepage/HompageUpdateLog.tsx";
import {HomepageAlertManager} from "@/ui/Homepage/HomepageAlertManager.tsx";
import {Button} from "@/components/ui/button.tsx";
import { getUserInfo, getDatabaseHealth } from "@/ui/main-axios.ts";
interface HomepageProps {
onSelectView: (view: string) => void;
isAuthenticated: boolean;
authLoading: boolean;
onAuthSuccess: (authData: { isAdmin: boolean; username: string | null; userId: string | null }) => void;
isTopbarOpen?: boolean;
}
function getCookie(name: string) {
return document.cookie.split('; ').reduce((r, v) => {
const parts = v.split('=');
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
}, "");
}
function setCookie(name: string, value: string, days = 7) {
const expires = new Date(Date.now() + days * 864e5).toUTCString();
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
}
export function Homepage({
onSelectView,
isAuthenticated,
authLoading,
onAuthSuccess,
isTopbarOpen = true
}: HomepageProps): React.ReactElement {
const [loggedIn, setLoggedIn] = useState(isAuthenticated);
const [isAdmin, setIsAdmin] = useState(false);
const [username, setUsername] = useState<string | null>(null);
const [userId, setUserId] = useState<string | null>(null);
const [dbError, setDbError] = useState<string | null>(null);
useEffect(() => {
setLoggedIn(isAuthenticated);
}, [isAuthenticated]);
useEffect(() => {
if (isAuthenticated) {
const jwt = getCookie("jwt");
if (jwt) {
Promise.all([
getUserInfo(),
getDatabaseHealth()
])
.then(([meRes]) => {
setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null);
setUserId(meRes.userId || null);
setDbError(null);
})
.catch((err) => {
setIsAdmin(false);
setUsername(null);
setUserId(null);
if (err?.response?.data?.error?.includes("Database")) {
setDbError("Could not connect to the database. Please try again later.");
} else {
setDbError(null);
}
});
}
}
}, [isAuthenticated]);
return (
<div
className={`w-full min-h-svh relative transition-[padding-top] duration-200 ease-linear ${
isTopbarOpen ? 'pt-[66px]' : 'pt-2'
}`}>
{!loggedIn ? (
<div className="absolute top-[66px] left-0 w-full h-[calc(100%-66px)] flex items-center justify-center">
<HomepageAuth
setLoggedIn={setLoggedIn}
setIsAdmin={setIsAdmin}
setUsername={setUsername}
setUserId={setUserId}
loggedIn={loggedIn}
authLoading={authLoading}
dbError={dbError}
setDbError={setDbError}
onAuthSuccess={onAuthSuccess}
/>
</div>
) : (
<div className="absolute top-[66px] left-0 w-full h-[calc(100%-66px)] flex items-center justify-center">
<div className="flex flex-row items-center justify-center gap-8 relative z-[10000]">
<div className="flex flex-col items-center gap-6 w-[400px]">
<div
className="text-center bg-[#18181b] border-2 border-[#303032] rounded-lg p-6 w-full shadow-lg">
<h3 className="text-xl font-bold mb-3 text-white">Logged in!</h3>
<p className="text-gray-300 leading-relaxed">
You are logged in! Use the sidebar to access all available tools. To get started,
create an SSH Host in the SSH Manager tab. Once created, you can connect to that
host using the other apps in the sidebar.
</p>
</div>
<div className="flex flex-row items-center gap-3">
<Button
variant="outline"
size="sm"
className="text-sm border-[#303032] text-gray-300 hover:text-white hover:bg-[#18181b] transition-colors"
onClick={() => window.open('https://github.com/LukeGus/Termix', '_blank')}
>
GitHub
</Button>
<div className="w-px h-4 bg-[#303032]"></div>
<Button
variant="outline"
size="sm"
className="text-sm border-[#303032] text-gray-300 hover:text-white hover:bg-[#18181b] transition-colors"
onClick={() => window.open('https://github.com/LukeGus/Termix/issues/new', '_blank')}
>
Feedback
</Button>
<div className="w-px h-4 bg-[#303032]"></div>
<Button
variant="outline"
size="sm"
className="text-sm border-[#303032] text-gray-300 hover:text-white hover:bg-[#18181b] transition-colors"
onClick={() => window.open('https://discord.com/invite/jVQGdvHDrf', '_blank')}
>
Discord
</Button>
<div className="w-px h-4 bg-[#303032]"></div>
<Button
variant="outline"
size="sm"
className="text-sm border-[#303032] text-gray-300 hover:text-white hover:bg-[#18181b] transition-colors"
onClick={() => window.open('https://github.com/sponsors/LukeGus', '_blank')}
>
Donate
</Button>
</div>
</div>
<HomepageUpdateLog
loggedIn={loggedIn}
/>
</div>
</div>
)}
<HomepageAlertManager
userId={userId}
loggedIn={loggedIn}
/>
</div>
);
}

View File

@@ -0,0 +1,150 @@
import React from "react";
import {Card, CardContent, CardFooter, CardHeader, CardTitle} from "@/components/ui/card.tsx";
import {Button} from "@/components/ui/button.tsx";
import {Badge} from "@/components/ui/badge.tsx";
import {X, ExternalLink, AlertTriangle, Info, CheckCircle, AlertCircle} from "lucide-react";
interface TermixAlert {
id: string;
title: string;
message: string;
expiresAt: string;
priority?: 'low' | 'medium' | 'high' | 'critical';
type?: 'info' | 'warning' | 'error' | 'success';
actionUrl?: string;
actionText?: string;
}
interface AlertCardProps {
alert: TermixAlert;
onDismiss: (alertId: string) => void;
onClose: () => void;
}
const getAlertIcon = (type?: string) => {
switch (type) {
case 'warning':
return <AlertTriangle className="h-5 w-5 text-yellow-500"/>;
case 'error':
return <AlertCircle className="h-5 w-5 text-red-500"/>;
case 'success':
return <CheckCircle className="h-5 w-5 text-green-500"/>;
case 'info':
default:
return <Info className="h-5 w-5 text-blue-500"/>;
}
};
const getPriorityBadgeVariant = (priority?: string) => {
switch (priority) {
case 'critical':
return 'destructive';
case 'high':
return 'destructive';
case 'medium':
return 'secondary';
case 'low':
default:
return 'outline';
}
};
const getTypeBadgeVariant = (type?: string) => {
switch (type) {
case 'warning':
return 'secondary';
case 'error':
return 'destructive';
case 'success':
return 'default';
case 'info':
default:
return 'outline';
}
};
export function HomepageAlertCard({alert, onDismiss, onClose}: AlertCardProps): React.ReactElement {
if (!alert) {
return null;
}
const handleDismiss = () => {
onDismiss(alert.id);
onClose();
};
const formatExpiryDate = (expiryString: string) => {
const expiryDate = new Date(expiryString);
const now = new Date();
const diffTime = expiryDate.getTime() - now.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays < 0) return 'Expired';
if (diffDays === 0) return 'Expires today';
if (diffDays === 1) return 'Expires tomorrow';
return `Expires in ${diffDays} days`;
};
return (
<Card className="w-full max-w-2xl mx-auto">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{getAlertIcon(alert.type)}
<CardTitle className="text-xl font-bold">
{alert.title}
</CardTitle>
</div>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4"/>
</Button>
</div>
<div className="flex items-center gap-2 mt-2">
{alert.priority && (
<Badge variant={getPriorityBadgeVariant(alert.priority)}>
{alert.priority.toUpperCase()}
</Badge>
)}
{alert.type && (
<Badge variant={getTypeBadgeVariant(alert.type)}>
{alert.type}
</Badge>
)}
<span className="text-sm text-muted-foreground">
{formatExpiryDate(alert.expiresAt)}
</span>
</div>
</CardHeader>
<CardContent className="pb-4">
<p className="text-muted-foreground leading-relaxed whitespace-pre-wrap">
{alert.message}
</p>
</CardContent>
<CardFooter className="flex items-center justify-between pt-0">
<div className="flex gap-2">
<Button
variant="outline"
onClick={handleDismiss}
>
Dismiss
</Button>
{alert.actionUrl && alert.actionText && (
<Button
variant="default"
onClick={() => window.open(alert.actionUrl, '_blank', 'noopener,noreferrer')}
className="gap-2"
>
{alert.actionText}
<ExternalLink className="h-4 w-4"/>
</Button>
)}
</div>
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,177 @@
import React, {useEffect, useState} from "react";
import {HomepageAlertCard} from "./HomepageAlertCard.tsx";
import {Button} from "@/components/ui/button.tsx";
import { getUserAlerts, dismissAlert } from "@/ui/main-axios.ts";
interface TermixAlert {
id: string;
title: string;
message: string;
expiresAt: string;
priority?: 'low' | 'medium' | 'high' | 'critical';
type?: 'info' | 'warning' | 'error' | 'success';
actionUrl?: string;
actionText?: string;
}
interface AlertManagerProps {
userId: string | null;
loggedIn: boolean;
}
export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): React.ReactElement {
const [alerts, setAlerts] = useState<TermixAlert[]>([]);
const [currentAlertIndex, setCurrentAlertIndex] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (loggedIn && userId) {
fetchUserAlerts();
}
}, [loggedIn, userId]);
const fetchUserAlerts = async () => {
if (!userId) return;
setLoading(true);
setError(null);
try {
const response = await getUserAlerts(userId);
const userAlerts = response.alerts || [];
const sortedAlerts = userAlerts.sort((a: TermixAlert, b: TermixAlert) => {
const priorityOrder = {critical: 4, high: 3, medium: 2, low: 1};
const aPriority = priorityOrder[a.priority as keyof typeof priorityOrder] || 0;
const bPriority = priorityOrder[b.priority as keyof typeof priorityOrder] || 0;
if (aPriority !== bPriority) {
return bPriority - aPriority;
}
return new Date(a.expiresAt).getTime() - new Date(b.expiresAt).getTime();
});
setAlerts(sortedAlerts);
setCurrentAlertIndex(0);
} catch (err) {
setError('Failed to load alerts');
} finally {
setLoading(false);
}
};
const handleDismissAlert = async (alertId: string) => {
if (!userId) return;
try {
await dismissAlert(userId, alertId);
setAlerts(prev => {
const newAlerts = prev.filter(alert => alert.id !== alertId);
return newAlerts;
});
setCurrentAlertIndex(prevIndex => {
const newAlertsLength = alerts.length - 1;
if (newAlertsLength === 0) return 0;
if (prevIndex >= newAlertsLength) return Math.max(0, newAlertsLength - 1);
return prevIndex;
});
} catch (err) {
setError('Failed to dismiss alert');
}
};
const handleCloseCurrentAlert = () => {
if (alerts.length === 0) return;
if (currentAlertIndex < alerts.length - 1) {
setCurrentAlertIndex(currentAlertIndex + 1);
} else {
setAlerts([]);
setCurrentAlertIndex(0);
}
};
const handlePreviousAlert = () => {
if (currentAlertIndex > 0) {
setCurrentAlertIndex(currentAlertIndex - 1);
}
};
const handleNextAlert = () => {
if (currentAlertIndex < alerts.length - 1) {
setCurrentAlertIndex(currentAlertIndex + 1);
}
};
if (!loggedIn || !userId) {
return null;
}
if (alerts.length === 0) {
return null;
}
const currentAlert = alerts[currentAlertIndex];
if (!currentAlert) {
return null;
}
const priorityCounts = {critical: 0, high: 0, medium: 0, low: 0};
alerts.forEach(alert => {
const priority = alert.priority || 'low';
priorityCounts[priority as keyof typeof priorityCounts]++;
});
const hasMultipleAlerts = alerts.length > 1;
return (
<div className="fixed inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-[99999]">
<div className="relative w-full max-w-2xl mx-4">
<HomepageAlertCard
alert={currentAlert}
onDismiss={handleDismissAlert}
onClose={handleCloseCurrentAlert}
/>
{hasMultipleAlerts && (
<div className="absolute -bottom-16 left-1/2 transform -translate-x-1/2 flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handlePreviousAlert}
disabled={currentAlertIndex === 0}
className="h-8 px-3"
>
Previous
</Button>
<span className="text-sm text-muted-foreground">
{currentAlertIndex + 1} of {alerts.length}
</span>
<Button
variant="outline"
size="sm"
onClick={handleNextAlert}
disabled={currentAlertIndex === alerts.length - 1}
className="h-8 px-3"
>
Next
</Button>
</div>
)}
{error && (
<div className="absolute -bottom-20 left-1/2 transform -translate-x-1/2">
<div className="bg-destructive text-destructive-foreground px-3 py-1 rounded text-sm">
{error}
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,795 @@
import React, {useState, useEffect} from "react";
import {cn} from "../../lib/utils.ts";
import {Button} from "../../components/ui/button.tsx";
import {Input} from "../../components/ui/input.tsx";
import {Label} from "../../components/ui/label.tsx";
import {Alert, AlertTitle, AlertDescription} from "../../components/ui/alert.tsx";
import {
registerUser,
loginUser,
getUserInfo,
getRegistrationAllowed,
getOIDCConfig,
getUserCount,
initiatePasswordReset,
verifyPasswordResetCode,
completePasswordReset,
getOIDCAuthorizeUrl,
verifyTOTPLogin
} from "../main-axios.ts";
function setCookie(name: string, value: string, days = 7) {
const expires = new Date(Date.now() + days * 864e5).toUTCString();
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
}
function getCookie(name: string) {
return document.cookie.split('; ').reduce((r, v) => {
const parts = v.split('=');
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
}, "");
}
interface HomepageAuthProps extends React.ComponentProps<"div"> {
setLoggedIn: (loggedIn: boolean) => void;
setIsAdmin: (isAdmin: boolean) => void;
setUsername: (username: string | null) => void;
setUserId: (userId: string | null) => void;
loggedIn: boolean;
authLoading: boolean;
dbError: string | null;
setDbError: (error: string | null) => void;
onAuthSuccess: (authData: { isAdmin: boolean; username: string | null; userId: string | null }) => void;
}
export function HomepageAuth({
className,
setLoggedIn,
setIsAdmin,
setUsername,
setUserId,
loggedIn,
authLoading,
dbError,
setDbError,
onAuthSuccess,
...props
}: HomepageAuthProps) {
const [tab, setTab] = useState<"login" | "signup" | "external" | "reset">("login");
const [localUsername, setLocalUsername] = useState("");
const [password, setPassword] = useState("");
const [signupConfirmPassword, setSignupConfirmPassword] = useState("");
const [loading, setLoading] = useState(false);
const [oidcLoading, setOidcLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [internalLoggedIn, setInternalLoggedIn] = useState(false);
const [firstUser, setFirstUser] = useState(false);
const [registrationAllowed, setRegistrationAllowed] = useState(true);
const [oidcConfigured, setOidcConfigured] = useState(false);
const [resetStep, setResetStep] = useState<"initiate" | "verify" | "newPassword">("initiate");
const [resetCode, setResetCode] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [tempToken, setTempToken] = useState("");
const [resetLoading, setResetLoading] = useState(false);
const [resetSuccess, setResetSuccess] = useState(false);
const [totpRequired, setTotpRequired] = useState(false);
const [totpCode, setTotpCode] = useState("");
const [totpTempToken, setTotpTempToken] = useState("");
const [totpLoading, setTotpLoading] = useState(false);
useEffect(() => {
setInternalLoggedIn(loggedIn);
}, [loggedIn]);
useEffect(() => {
getRegistrationAllowed().then(res => {
setRegistrationAllowed(res.allowed);
});
}, []);
useEffect(() => {
getOIDCConfig().then((response) => {
if (response) {
setOidcConfigured(true);
} else {
setOidcConfigured(false);
}
}).catch((error) => {
if (error.response?.status === 404) {
setOidcConfigured(false);
} else {
setOidcConfigured(false);
}
});
}, []);
useEffect(() => {
getUserCount().then(res => {
if (res.count === 0) {
setFirstUser(true);
setTab("signup");
} else {
setFirstUser(false);
}
setDbError(null);
}).catch(() => {
setDbError("Could not connect to the database. Please try again later.");
});
}, [setDbError]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setLoading(true);
if (!localUsername.trim()) {
setError("Username is required");
setLoading(false);
return;
}
try {
let res, meRes;
if (tab === "login") {
res = await loginUser(localUsername, password);
} else {
if (password !== signupConfirmPassword) {
setError("Passwords do not match");
setLoading(false);
return;
}
if (password.length < 6) {
setError("Password must be at least 6 characters long");
setLoading(false);
return;
}
await registerUser(localUsername, password);
res = await loginUser(localUsername, password);
}
if (res.requires_totp) {
setTotpRequired(true);
setTotpTempToken(res.temp_token);
setLoading(false);
return;
}
if (!res || !res.token) {
throw new Error('No token received from login');
}
setCookie("jwt", res.token);
[meRes] = await Promise.all([
getUserInfo(),
]);
setInternalLoggedIn(true);
setLoggedIn(true);
setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null);
setUserId(meRes.userId || null);
setDbError(null);
onAuthSuccess({
isAdmin: !!meRes.is_admin,
username: meRes.username || null,
userId: meRes.userId || null
});
setInternalLoggedIn(true);
if (tab === "signup") {
setSignupConfirmPassword("");
}
setTotpRequired(false);
setTotpCode("");
setTotpTempToken("");
} catch (err: any) {
setError(err?.response?.data?.error || err?.message || "Unknown error");
setInternalLoggedIn(false);
setLoggedIn(false);
setIsAdmin(false);
setUsername(null);
setUserId(null);
setCookie("jwt", "", -1);
if (err?.response?.data?.error?.includes("Database")) {
setDbError("Could not connect to the database. Please try again later.");
} else {
setDbError(null);
}
} finally {
setLoading(false);
}
}
async function handleInitiatePasswordReset() {
setError(null);
setResetLoading(true);
try {
const result = await initiatePasswordReset(localUsername);
setResetStep("verify");
setError(null);
} catch (err: any) {
setError(err?.response?.data?.error || err?.message || "Failed to initiate password reset");
} finally {
setResetLoading(false);
}
}
async function handleVerifyResetCode() {
setError(null);
setResetLoading(true);
try {
const response = await verifyPasswordResetCode(localUsername, resetCode);
setTempToken(response.tempToken);
setResetStep("newPassword");
setError(null);
} catch (err: any) {
setError(err?.response?.data?.error || "Failed to verify reset code");
} finally {
setResetLoading(false);
}
}
async function handleCompletePasswordReset() {
setError(null);
setResetLoading(true);
if (newPassword !== confirmPassword) {
setError("Passwords do not match");
setResetLoading(false);
return;
}
if (newPassword.length < 6) {
setError("Password must be at least 6 characters long");
setResetLoading(false);
return;
}
try {
await completePasswordReset(localUsername, tempToken, newPassword);
setResetStep("initiate");
setResetCode("");
setNewPassword("");
setConfirmPassword("");
setTempToken("");
setError(null);
setResetSuccess(true);
} catch (err: any) {
setError(err?.response?.data?.error || "Failed to complete password reset");
} finally {
setResetLoading(false);
}
}
function resetPasswordState() {
setResetStep("initiate");
setResetCode("");
setNewPassword("");
setConfirmPassword("");
setTempToken("");
setError(null);
setResetSuccess(false);
setSignupConfirmPassword("");
}
function clearFormFields() {
setPassword("");
setSignupConfirmPassword("");
setError(null);
}
async function handleTOTPVerification() {
if (totpCode.length !== 6) {
setError("Please enter a 6-digit code");
return;
}
setError(null);
setTotpLoading(true);
try {
const res = await verifyTOTPLogin(totpTempToken, totpCode);
if (!res || !res.token) {
throw new Error('No token received from TOTP verification');
}
setCookie("jwt", res.token);
const meRes = await getUserInfo();
setInternalLoggedIn(true);
setLoggedIn(true);
setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null);
setUserId(meRes.userId || null);
setDbError(null);
onAuthSuccess({
isAdmin: !!meRes.is_admin,
username: meRes.username || null,
userId: meRes.userId || null
});
setInternalLoggedIn(true);
setTotpRequired(false);
setTotpCode("");
setTotpTempToken("");
} catch (err: any) {
setError(err?.response?.data?.error || err?.message || "Invalid TOTP code");
} finally {
setTotpLoading(false);
}
}
async function handleOIDCLogin() {
setError(null);
setOidcLoading(true);
try {
const authResponse = await getOIDCAuthorizeUrl();
const {auth_url: authUrl} = authResponse;
if (!authUrl || authUrl === 'undefined') {
throw new Error('Invalid authorization URL received from backend');
}
window.location.replace(authUrl);
} catch (err: any) {
setError(err?.response?.data?.error || err?.message || "Failed to start OIDC login");
setOidcLoading(false);
}
}
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const success = urlParams.get('success');
const token = urlParams.get('token');
const error = urlParams.get('error');
if (error) {
setError(`OIDC authentication failed: ${error}`);
setOidcLoading(false);
window.history.replaceState({}, document.title, window.location.pathname);
return;
}
if (success && token) {
setOidcLoading(true);
setError(null);
setCookie("jwt", token);
getUserInfo()
.then(meRes => {
setInternalLoggedIn(true);
setLoggedIn(true);
setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null);
setUserId(meRes.id || null);
setDbError(null);
onAuthSuccess({
isAdmin: !!meRes.is_admin,
username: meRes.username || null,
userId: meRes.id || null
});
setInternalLoggedIn(true);
window.history.replaceState({}, document.title, window.location.pathname);
})
.catch(err => {
setError("Failed to get user info after OIDC login");
setInternalLoggedIn(false);
setLoggedIn(false);
setIsAdmin(false);
setUsername(null);
setUserId(null);
setCookie("jwt", "", -1);
window.history.replaceState({}, document.title, window.location.pathname);
})
.finally(() => {
setOidcLoading(false);
});
}
}, []);
const Spinner = (
<svg className="animate-spin mr-2 h-4 w-4 text-white inline-block" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
</svg>
);
return (
<div
className={`w-[420px] max-w-full p-6 flex flex-col bg-[#18181b] border-2 border-[#303032] rounded-md ${className || ''}`}
{...props}
>
{dbError && (
<Alert variant="destructive" className="mb-4">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{dbError}</AlertDescription>
</Alert>
)}
{firstUser && !dbError && !internalLoggedIn && (
<Alert variant="default" className="mb-4">
<AlertTitle>First User</AlertTitle>
<AlertDescription className="inline">
You are the first user and will be made an admin. You can view admin settings in the sidebar
user dropdown. If you think this is a mistake, check the docker logs, or create a{" "}
<a
href="https://github.com/LukeGus/Termix/issues/new"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 underline hover:text-blue-800 inline"
>
GitHub issue
</a>.
</AlertDescription>
</Alert>
)}
{!registrationAllowed && !internalLoggedIn && (
<Alert variant="destructive" className="mb-4">
<AlertTitle>Registration Disabled</AlertTitle>
<AlertDescription>
New account registration is currently disabled by an admin. Please log in or contact an
administrator.
</AlertDescription>
</Alert>
)}
{totpRequired && (
<div className="flex flex-col gap-5">
<div className="mb-6 text-center">
<h2 className="text-xl font-bold mb-1">Two-Factor Authentication</h2>
<p className="text-muted-foreground">Enter the 6-digit code from your authenticator app</p>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="totp-code">Authentication Code</Label>
<Input
id="totp-code"
type="text"
placeholder="000000"
maxLength={6}
value={totpCode}
onChange={e => setTotpCode(e.target.value.replace(/\D/g, ''))}
disabled={totpLoading}
className="text-center text-2xl tracking-widest font-mono"
autoComplete="one-time-code"
/>
<p className="text-xs text-muted-foreground text-center">
Or enter a backup code if you don't have access to your authenticator
</p>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={totpLoading || totpCode.length < 6}
onClick={handleTOTPVerification}
>
{totpLoading ? Spinner : "Verify"}
</Button>
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={totpLoading}
onClick={() => {
setTotpRequired(false);
setTotpCode("");
setTotpTempToken("");
setError(null);
}}
>
Cancel
</Button>
</div>
)}
{(!internalLoggedIn && (!authLoading || !getCookie("jwt")) && !totpRequired) && (
<>
<div className="flex gap-2 mb-6">
<button
type="button"
className={cn(
"flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "login"
? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent"
)}
onClick={() => {
setTab("login");
if (tab === "reset") resetPasswordState();
if (tab === "signup") clearFormFields();
}}
aria-selected={tab === "login"}
disabled={loading || firstUser}
>
Login
</button>
<button
type="button"
className={cn(
"flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "signup"
? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent"
)}
onClick={() => {
setTab("signup");
if (tab === "reset") resetPasswordState();
if (tab === "login") clearFormFields();
}}
aria-selected={tab === "signup"}
disabled={loading || !registrationAllowed}
>
Sign Up
</button>
{oidcConfigured && (
<button
type="button"
className={cn(
"flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "external"
? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent"
)}
onClick={() => {
setTab("external");
if (tab === "reset") resetPasswordState();
if (tab === "login" || tab === "signup") clearFormFields();
}}
aria-selected={tab === "external"}
disabled={oidcLoading}
>
External
</button>
)}
</div>
<div className="mb-6 text-center">
<h2 className="text-xl font-bold mb-1">
{tab === "login" ? "Login to your account" :
tab === "signup" ? "Create a new account" :
tab === "external" ? "Login with external provider" :
"Reset your password"}
</h2>
</div>
{tab === "external" || tab === "reset" ? (
<div className="flex flex-col gap-5">
{tab === "external" && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>Login using your configured external identity provider</p>
</div>
<Button
type="button"
className="w-full h-11 mt-2 text-base font-semibold"
disabled={oidcLoading}
onClick={handleOIDCLogin}
>
{oidcLoading ? Spinner : "Login with External Provider"}
</Button>
</>
)}
{tab === "reset" && (
<>
{resetStep === "initiate" && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>Enter your username to receive a password reset code. The code
will be logged in the docker container logs.</p>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="reset-username">Username</Label>
<Input
id="reset-username"
type="text"
required
className="h-11 text-base"
value={localUsername}
onChange={e => setLocalUsername(e.target.value)}
disabled={resetLoading}
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading || !localUsername.trim()}
onClick={handleInitiatePasswordReset}
>
{resetLoading ? Spinner : "Send Reset Code"}
</Button>
</div>
</>
)}
{resetStep === "verify" && (
<>o
<div className="text-center text-muted-foreground mb-4">
<p>Enter the 6-digit code from the docker container logs for
user: <strong>{localUsername}</strong></p>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="reset-code">Reset Code</Label>
<Input
id="reset-code"
type="text"
required
maxLength={6}
className="h-11 text-base text-center text-lg tracking-widest"
value={resetCode}
onChange={e => setResetCode(e.target.value.replace(/\D/g, ''))}
disabled={resetLoading}
placeholder="000000"
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading || resetCode.length !== 6}
onClick={handleVerifyResetCode}
>
{resetLoading ? Spinner : "Verify Code"}
</Button>
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading}
onClick={() => {
setResetStep("initiate");
setResetCode("");
}}
>
Back
</Button>
</div>
</>
)}
{resetSuccess && (
<>
<Alert className="mb-4">
<AlertTitle>Success!</AlertTitle>
<AlertDescription>
Your password has been successfully reset! You can now log in
with your new password.
</AlertDescription>
</Alert>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
onClick={() => {
setTab("login");
resetPasswordState();
}}
>
Go to Login
</Button>
</>
)}
{resetStep === "newPassword" && !resetSuccess && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>Enter your new password for
user: <strong>{localUsername}</strong></p>
</div>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-2">
<Label htmlFor="new-password">New Password</Label>
<Input
id="new-password"
type="password"
required
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
disabled={resetLoading}
autoComplete="new-password"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="confirm-password">Confirm Password</Label>
<Input
id="confirm-password"
type="password"
required
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
disabled={resetLoading}
autoComplete="new-password"
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading || !newPassword || !confirmPassword}
onClick={handleCompletePasswordReset}
>
{resetLoading ? Spinner : "Reset Password"}
</Button>
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading}
onClick={() => {
setResetStep("verify");
setNewPassword("");
setConfirmPassword("");
}}
>
Back
</Button>
</div>
</>
)}
</>
)}
</div>
) : (
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
type="text"
required
className="h-11 text-base"
value={localUsername}
onChange={e => setLocalUsername(e.target.value)}
disabled={loading || internalLoggedIn}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="password">Password</Label>
<Input id="password" type="password" required className="h-11 text-base"
value={password} onChange={e => setPassword(e.target.value)}
disabled={loading || internalLoggedIn}/>
</div>
{tab === "signup" && (
<div className="flex flex-col gap-2">
<Label htmlFor="signup-confirm-password">Confirm Password</Label>
<Input id="signup-confirm-password" type="password" required
className="h-11 text-base"
value={signupConfirmPassword}
onChange={e => setSignupConfirmPassword(e.target.value)}
disabled={loading || internalLoggedIn}/>
</div>
)}
<Button type="submit" className="w-full h-11 mt-2 text-base font-semibold"
disabled={loading || internalLoggedIn}>
{loading ? Spinner : (tab === "login" ? "Login" : "Sign Up")}
</Button>
{tab === "login" && (
<Button type="button" variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={loading || internalLoggedIn}
onClick={() => {
setTab("reset");
resetPasswordState();
clearFormFields();
}}
>
Reset Password
</Button>
)}
</form>
)}
</>
)}
{error && (
<Alert variant="destructive" className="mt-4">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</div>
);
}

View File

@@ -2,7 +2,7 @@ import React, {useEffect, useState} from "react";
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
import {Button} from "@/components/ui/button.tsx";
import {Separator} from "@/components/ui/separator.tsx";
import axios from "axios";
import { getReleasesRSS, getVersionInfo } from "@/ui/main-axios.ts";
interface HomepageUpdateLogProps extends React.ComponentProps<"div"> {
loggedIn: boolean;
@@ -50,12 +50,6 @@ interface VersionResponse {
cache_age?: number;
}
const apiBase = import.meta.env.DEV ? "http://localhost:8081" : "";
const API = axios.create({
baseURL: apiBase,
});
export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
const [releases, setReleases] = useState<RSSResponse | null>(null);
const [versionInfo, setVersionInfo] = useState<VersionResponse | null>(null);
@@ -66,12 +60,12 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
if (loggedIn) {
setLoading(true);
Promise.all([
API.get('/releases/rss?per_page=100'),
API.get('/version/')
getReleasesRSS(100),
getVersionInfo()
])
.then(([releasesRes, versionRes]) => {
setReleases(releasesRes.data);
setVersionInfo(versionRes.data);
setReleases(releasesRes);
setVersionInfo(versionRes);
setError(null);
})
.catch(err => {
@@ -90,75 +84,67 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
return firstLine
.replace(/[#*`]/g, '')
.replace(/\s+/g, ' ')
.trim()
.substring(0, 100) + (firstLine.length > 100 ? '...' : '');
.trim();
};
return (
<div className="w-[400px] h-[600px] flex flex-col border border-border rounded-lg bg-card p-4">
<div className="w-[400px] h-[600px] flex flex-col border-2 border-[#303032] rounded-lg bg-[#18181b] p-4 shadow-lg">
<div>
<h3 className="text-lg font-semibold mb-3">Updates & Releases</h3>
<h3 className="text-lg font-bold mb-3 text-white">Updates & Releases</h3>
<Separator className="p-0.25 mt-3 mb-3"/>
<Separator className="p-0.25 mt-3 mb-3 bg-[#303032]"/>
{versionInfo && versionInfo.status === 'requires_update' && (
<Alert>
<AlertTitle>Update Available</AlertTitle>
<AlertDescription>
<Alert className="bg-[#0e0e10] border-[#303032] text-white">
<AlertTitle className="text-white">Update Available</AlertTitle>
<AlertDescription className="text-gray-300">
A new version ({versionInfo.version}) is available.
<Button
variant="link"
className="p-0 h-auto underline ml-1"
onClick={() => window.open("https://docs.termix.site/docs", '_blank')}
>
Update now
</Button>
</AlertDescription>
</Alert>
)}
</div>
{versionInfo && versionInfo.status === 'requires_update' && (
<Separator className="p-0.25 mt-3 mb-3"/>
<Separator className="p-0.25 mt-3 mb-3 bg-[#303032]"/>
)}
<div className="flex-1 overflow-y-auto space-y-3">
<div className="flex-1 overflow-y-auto space-y-3 pr-2">
{loading && (
<div className="flex items-center justify-center h-32">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
</div>
)}
{error && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
<Alert variant="destructive" className="bg-red-900/20 border-red-500 text-red-300">
<AlertTitle className="text-red-300">Error</AlertTitle>
<AlertDescription className="text-red-300">{error}</AlertDescription>
</Alert>
)}
{releases?.items.map((release) => (
<div
key={release.id}
className="border border-border rounded-lg p-3 hover:bg-accent transition-colors cursor-pointer"
className="border border-[#303032] rounded-lg p-3 hover:bg-[#0e0e10] transition-colors cursor-pointer bg-[#0e0e10]/50"
onClick={() => window.open(release.link, '_blank')}
>
<div className="flex items-start justify-between mb-2">
<h4 className="font-medium text-sm leading-tight flex-1">
<h4 className="font-semibold text-sm leading-tight flex-1 text-white">
{release.title}
</h4>
{release.isPrerelease && (
<span
className="text-xs bg-yellow-100 text-yellow-800 px-2 py-1 rounded ml-2 flex-shrink-0">
className="text-xs bg-yellow-600 text-yellow-100 px-2 py-1 rounded ml-2 flex-shrink-0 font-medium">
Pre-release
</span>
)}
</div>
<p className="text-xs text-muted-foreground mb-2 leading-relaxed">
<p className="text-xs text-gray-300 mb-2 leading-relaxed">
{formatDescription(release.description)}
</p>
<div className="flex items-center text-xs text-muted-foreground">
<div className="flex items-center text-xs text-gray-400">
<span>{new Date(release.pubDate).toLocaleDateString()}</span>
{release.assets.length > 0 && (
<>
@@ -171,9 +157,9 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
))}
{releases && releases.items.length === 0 && !loading && (
<Alert>
<AlertTitle>No Releases</AlertTitle>
<AlertDescription>
<Alert className="bg-[#0e0e10] border-[#303032] text-gray-300">
<AlertTitle className="text-gray-300">No Releases</AlertTitle>
<AlertDescription className="text-gray-400">
No releases found.
</AlertDescription>
</Alert>

View File

@@ -0,0 +1,553 @@
import React, {useEffect, useRef, useState} from "react";
import {Terminal} from "@/ui/Apps/Terminal/Terminal.tsx";
import {Server as ServerView} from "@/ui/Apps/Server/Server.tsx";
import {FileManager} from "@/ui/Apps/File Manager/FileManager.tsx";
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
import {ResizablePanelGroup, ResizablePanel, ResizableHandle} from '@/components/ui/resizable.tsx';
import * as ResizablePrimitive from "react-resizable-panels";
import {useSidebar} from "@/components/ui/sidebar.tsx";
import {LucideRefreshCcw, LucideRefreshCw, RefreshCcw, RefreshCcwDot} from "lucide-react";
import {Button} from "@/components/ui/button.tsx";
interface TerminalViewProps {
isTopbarOpen?: boolean;
}
export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactElement {
const {tabs, currentTab, allSplitScreenTab} = useTabs() as any;
const {state: sidebarState} = useSidebar();
const terminalTabs = tabs.filter((tab: any) => tab.type === 'terminal' || tab.type === 'server' || tab.type === 'file_manager');
const containerRef = useRef<HTMLDivElement | null>(null);
const panelRefs = useRef<Record<string, HTMLDivElement | null>>({});
const [panelRects, setPanelRects] = useState<Record<string, DOMRect | null>>({});
const [ready, setReady] = useState<boolean>(true);
const [resetKey, setResetKey] = useState<number>(0);
const updatePanelRects = () => {
const next: Record<string, DOMRect | null> = {};
Object.entries(panelRefs.current).forEach(([id, el]) => {
if (el) next[id] = el.getBoundingClientRect();
});
setPanelRects(next);
};
const fitActiveAndNotify = () => {
const visibleIds: number[] = [];
if (allSplitScreenTab.length === 0) {
if (currentTab) visibleIds.push(currentTab);
} else {
const splitIds = allSplitScreenTab as number[];
visibleIds.push(currentTab, ...splitIds.filter((i) => i !== currentTab));
}
terminalTabs.forEach((t: any) => {
if (visibleIds.includes(t.id)) {
const ref = t.terminalRef?.current;
if (ref?.fit) ref.fit();
if (ref?.notifyResize) ref.notifyResize();
if (ref?.refresh) ref.refresh();
}
});
};
const layoutScheduleRef = useRef<number | null>(null);
const scheduleMeasureAndFit = () => {
if (layoutScheduleRef.current) cancelAnimationFrame(layoutScheduleRef.current);
layoutScheduleRef.current = requestAnimationFrame(() => {
updatePanelRects();
layoutScheduleRef.current = requestAnimationFrame(() => {
fitActiveAndNotify();
});
});
};
const hideThenFit = () => {
setReady(false);
requestAnimationFrame(() => {
updatePanelRects();
requestAnimationFrame(() => {
fitActiveAndNotify();
setReady(true);
});
});
};
useEffect(() => {
hideThenFit();
}, [currentTab, terminalTabs.length, allSplitScreenTab.join(',')]);
useEffect(() => {
scheduleMeasureAndFit();
}, [allSplitScreenTab.length, isTopbarOpen, sidebarState, resetKey]);
useEffect(() => {
const roContainer = containerRef.current ? new ResizeObserver(() => {
updatePanelRects();
fitActiveAndNotify();
}) : null;
if (containerRef.current && roContainer) roContainer.observe(containerRef.current);
return () => roContainer?.disconnect();
}, []);
useEffect(() => {
const onWinResize = () => {
updatePanelRects();
fitActiveAndNotify();
};
window.addEventListener('resize', onWinResize);
return () => window.removeEventListener('resize', onWinResize);
}, []);
const HEADER_H = 28;
const renderTerminalsLayer = () => {
const styles: Record<number, React.CSSProperties> = {};
const splitTabs = terminalTabs.filter((tab: any) => allSplitScreenTab.includes(tab.id));
const mainTab = terminalTabs.find((tab: any) => tab.id === currentTab);
const layoutTabs = [mainTab, ...splitTabs.filter((t: any) => t && t.id !== (mainTab && (mainTab as any).id))].filter(Boolean) as any[];
if (allSplitScreenTab.length === 0 && mainTab) {
const isFileManagerTab = mainTab.type === 'file_manager';
styles[mainTab.id] = {
position: 'absolute',
top: isFileManagerTab ? 0 : 2,
left: isFileManagerTab ? 0 : 2,
right: isFileManagerTab ? 0 : 2,
bottom: isFileManagerTab ? 0 : 2,
zIndex: 20,
display: 'block',
pointerEvents: 'auto',
opacity: ready ? 1 : 0
};
} else {
layoutTabs.forEach((t: any) => {
const rect = panelRects[String(t.id)];
const parentRect = containerRef.current?.getBoundingClientRect();
if (rect && parentRect) {
styles[t.id] = {
position: 'absolute',
top: (rect.top - parentRect.top) + HEADER_H + 2,
left: (rect.left - parentRect.left) + 2,
width: rect.width - 4,
height: rect.height - HEADER_H - 4,
zIndex: 20,
display: 'block',
pointerEvents: 'auto',
opacity: ready ? 1 : 0,
};
}
});
}
return (
<div style={{position: 'absolute', inset: 0, zIndex: 1}}>
{terminalTabs.map((t: any) => {
const hasStyle = !!styles[t.id];
const isVisible = hasStyle || (allSplitScreenTab.length === 0 && t.id === currentTab);
const finalStyle: React.CSSProperties = hasStyle
? {...styles[t.id], overflow: 'hidden'}
: {
position: 'absolute', inset: 0, visibility: 'hidden', pointerEvents: 'none', zIndex: 0,
} as React.CSSProperties;
const effectiveVisible = isVisible && ready;
return (
<div key={t.id} style={finalStyle}>
<div className="absolute inset-0 rounded-md bg-[#18181b]">
{t.type === 'terminal' ? (
<Terminal
ref={t.terminalRef}
hostConfig={t.hostConfig}
isVisible={effectiveVisible}
title={t.title}
showTitle={false}
splitScreen={allSplitScreenTab.length > 0}
/>
) : t.type === 'server' ? (
<ServerView
hostConfig={t.hostConfig}
title={t.title}
isVisible={effectiveVisible}
isTopbarOpen={isTopbarOpen}
embedded
/>
) : (
<FileManager
embedded
initialHost={t.hostConfig}
/>
)}
</div>
</div>
);
})}
</div>
);
};
const ResetButton = ({onClick}: { onClick: () => void }) => (
<Button
type="button"
variant="ghost"
onClick={onClick}
aria-label="Reset split sizes"
className="absolute top-0 right-0 h-[28px] w-[28px] !rounded-none border-l-1 border-b-1 border-[#222224] bg-[#1b1b1e] hover:bg-[#232327] text-white flex items-center justify-center p-0"
>
<RefreshCcw className="h-4 w-4"/>
</Button>
);
const handleReset = () => {
setResetKey((k) => k + 1);
requestAnimationFrame(() => scheduleMeasureAndFit());
};
const renderSplitOverlays = () => {
const splitTabs = terminalTabs.filter((tab: any) => allSplitScreenTab.includes(tab.id));
const mainTab = terminalTabs.find((tab: any) => tab.id === currentTab);
const layoutTabs = [mainTab, ...splitTabs.filter((t: any) => t && t.id !== (mainTab && (mainTab as any).id))].filter(Boolean) as any[];
if (allSplitScreenTab.length === 0) return null;
const handleStyle = {pointerEvents: 'auto', zIndex: 12, background: '#303032'} as React.CSSProperties;
const commonGroupProps = {onLayout: scheduleMeasureAndFit, onResize: scheduleMeasureAndFit} as any;
if (layoutTabs.length === 2) {
const [a, b] = layoutTabs as any[];
return (
<div style={{position: 'absolute', inset: 0, zIndex: 10, pointerEvents: 'none'}}>
<ResizablePrimitive.PanelGroup key={resetKey} direction="horizontal"
className="h-full w-full" {...commonGroupProps}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id={`panel-${a.id}`} order={1}>
<div ref={el => {
panelRefs.current[String(a.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>{a.title}</div>
</div>
</ResizablePanel>
<ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id={`panel-${b.id}`} order={2}>
<div ref={el => {
panelRefs.current[String(b.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>
{b.title}
<ResetButton onClick={handleReset}/>
</div>
</div>
</ResizablePanel>
</ResizablePrimitive.PanelGroup>
</div>
);
}
if (layoutTabs.length === 3) {
const [a, b, c] = layoutTabs as any[];
return (
<div style={{position: 'absolute', inset: 0, zIndex: 10, pointerEvents: 'none'}}>
<ResizablePrimitive.PanelGroup key={resetKey} direction="vertical" className="h-full w-full"
id="main-vertical" {...commonGroupProps}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id="top-panel" order={1}>
<ResizablePanelGroup key={`top-${resetKey}`} direction="horizontal"
className="h-full w-full" id="top-horizontal" {...commonGroupProps}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id={`panel-${a.id}`} order={1}>
<div ref={el => {
panelRefs.current[String(a.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>{a.title}</div>
</div>
</ResizablePanel>
<ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id={`panel-${b.id}`} order={2}>
<div ref={el => {
panelRefs.current[String(b.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>
{b.title}
<ResetButton onClick={handleReset}/>
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id="bottom-panel" order={2}>
<div ref={el => {
panelRefs.current[String(c.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>{c.title}</div>
</div>
</ResizablePanel>
</ResizablePrimitive.PanelGroup>
</div>
);
}
if (layoutTabs.length === 4) {
const [a, b, c, d] = layoutTabs as any[];
return (
<div style={{position: 'absolute', inset: 0, zIndex: 10, pointerEvents: 'none'}}>
<ResizablePrimitive.PanelGroup key={resetKey} direction="vertical" className="h-full w-full"
id="main-vertical" {...commonGroupProps}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id="top-panel" order={1}>
<ResizablePanelGroup key={`top-${resetKey}`} direction="horizontal"
className="h-full w-full" id="top-horizontal" {...commonGroupProps}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
id={`panel-${a.id}`} order={1}>
<div ref={el => {
panelRefs.current[String(a.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>{a.title}</div>
</div>
</ResizablePanel>
<ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
id={`panel-${b.id}`} order={2}>
<div ref={el => {
panelRefs.current[String(b.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>
{b.title}
<ResetButton onClick={handleReset}/>
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
id="bottom-panel" order={2}>
<ResizablePanelGroup key={`bottom-${resetKey}`} direction="horizontal"
className="h-full w-full" id="bottom-horizontal" {...commonGroupProps}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
id={`panel-${c.id}`} order={1}>
<div ref={el => {
panelRefs.current[String(c.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>{c.title}</div>
</div>
</ResizablePanel>
<ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
id={`panel-${d.id}`} order={2}>
<div ref={el => {
panelRefs.current[String(d.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>{d.title}</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
</ResizablePrimitive.PanelGroup>
</div>
);
}
return null;
};
const currentTabData = tabs.find((tab: any) => tab.id === currentTab);
const isFileManager = currentTabData?.type === 'file_manager';
const isSplitScreen = allSplitScreenTab.length > 0;
const topMarginPx = isTopbarOpen ? 74 : 26;
const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8;
const bottomMarginPx = 8;
return (
<div
ref={containerRef}
className="border-2 border-[#303032] rounded-lg overflow-hidden overflow-x-hidden"
style={{
position: 'relative',
background: (isFileManager && !isSplitScreen) ? '#09090b' : '#18181b',
marginLeft: leftMarginPx,
marginRight: 17,
marginTop: topMarginPx,
marginBottom: bottomMarginPx,
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
}}
>
{renderTerminalsLayer()}
{renderSplitOverlays()}
</div>
);
}

View File

@@ -0,0 +1,81 @@
import React, {useState} from "react";
import {CardTitle} from "@/components/ui/card.tsx";
import {ChevronDown, Folder} from "lucide-react";
import {Button} from "@/components/ui/button.tsx";
import {Host} from "@/ui/Navigation/Hosts/Host.tsx";
import {Separator} from "@/components/ui/separator.tsx";
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
updatedAt: string;
}
interface FolderCardProps {
folderName: string;
hosts: SSHHost[];
isFirst: boolean;
isLast: boolean;
}
export function FolderCard({folderName, hosts, isFirst, isLast}: FolderCardProps): React.ReactElement {
const [isExpanded, setIsExpanded] = useState(true);
const toggleExpanded = () => {
setIsExpanded(!isExpanded);
};
return (
<div className="bg-[#0e0e10] border-2 border-[#303032] rounded-lg overflow-hidden"
style={{padding: '0', margin: '0'}}>
<div className={`px-4 py-3 relative ${isExpanded ? 'border-b-2' : ''} bg-[#131316]`}>
<div className="flex gap-2 pr-10">
<div className="flex-shrink-0 flex items-center">
<Folder size={16} strokeWidth={3}/>
</div>
<div className="flex-1 min-w-0">
<CardTitle className="mb-0 leading-tight break-words text-md">{folderName}</CardTitle>
</div>
</div>
<Button
variant="outline"
className="w-[28px] h-[28px] absolute right-4 top-1/2 -translate-y-1/2 flex-shrink-0"
onClick={toggleExpanded}
>
<ChevronDown className={`h-4 w-4 transition-transform ${isExpanded ? '' : 'rotate-180'}`}/>
</Button>
</div>
{isExpanded && (
<div className="flex flex-col p-2 gap-y-3">
{hosts.map((host, index) => (
<React.Fragment key={`${folderName}-host-${host.id}-${host.name || host.ip}`}>
<Host host={host}/>
{index < hosts.length - 1 && (
<div className="relative -mx-2">
<Separator className="p-0.25 absolute inset-x-0"/>
</div>
)}
</React.Fragment>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,112 @@
import React, {useEffect, useState} from "react";
import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status";
import {Button} from "@/components/ui/button.tsx";
import {ButtonGroup} from "@/components/ui/button-group.tsx";
import {Server, Terminal} from "lucide-react";
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
import {getServerStatusById} from "@/ui/main-axios.ts";
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
updatedAt: string;
}
interface HostProps {
host: SSHHost;
}
export function Host({host}: HostProps): React.ReactElement {
const {addTab} = useTabs();
const [serverStatus, setServerStatus] = useState<'online' | 'offline' | 'degraded'>('degraded');
const tags = Array.isArray(host.tags) ? host.tags : [];
const hasTags = tags.length > 0;
const title = host.name?.trim() ? host.name : `${host.username}@${host.ip}:${host.port}`;
useEffect(() => {
let intervalId: number | undefined;
let cancelled = false;
const fetchStatus = async () => {
try {
const res = await getServerStatusById(host.id);
if (!cancelled) {
setServerStatus(res?.status === 'online' ? 'online' : 'offline');
}
} catch {
if (!cancelled) setServerStatus('offline');
}
};
fetchStatus();
intervalId = window.setInterval(fetchStatus, 10000);
return () => {
cancelled = true;
if (intervalId) window.clearInterval(intervalId);
};
}, [host.id]);
const handleTerminalClick = () => {
addTab({type: 'terminal', title, hostConfig: host});
};
const handleServerClick = () => {
addTab({type: 'server', title, hostConfig: host});
};
return (
<div>
<div className="flex items-center gap-2">
<Status status={serverStatus} className="!bg-transparent !p-0.75 flex-shrink-0">
<StatusIndicator/>
</Status>
<p className="font-semibold flex-1 min-w-0 break-words text-sm">
{host.name || host.ip}
</p>
<ButtonGroup className="flex-shrink-0">
<Button variant="outline" className="!px-2 border-1 border-[#303032]" onClick={handleServerClick}>
<Server/>
</Button>
{host.enableTerminal && (
<Button
variant="outline"
className="!px-2 border-1 border-[#303032]"
onClick={handleTerminalClick}
>
<Terminal/>
</Button>
)}
</ButtonGroup>
</div>
{hasTags && (
<div className="flex flex-wrap items-center gap-2 mt-1">
{tags.map((tag: string) => (
<div key={tag} className="bg-[#18181b] border-1 border-[#303032] pl-2 pr-2 rounded-[10px]">
<p className="text-sm">{tag}</p>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,692 @@
import React, {useState} from 'react';
import {
Computer,
Server,
File,
Hammer, ChevronUp, User2, HardDrive, Trash2, Users, Shield, Settings, Menu, ChevronRight
} from "lucide-react";
import {
Sidebar,
SidebarContent, SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem, SidebarProvider, SidebarInset, SidebarHeader,
} from "@/components/ui/sidebar.tsx"
import {
Separator,
} from "@/components/ui/separator.tsx"
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@radix-ui/react-dropdown-menu";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
SheetClose
} from "@/components/ui/sheet";
import {Checkbox} from "@/components/ui/checkbox.tsx";
import {Input} from "@/components/ui/input.tsx";
import {Label} from "@/components/ui/label.tsx";
import {Button} from "@/components/ui/button.tsx";
import {Alert, AlertTitle, AlertDescription} from "@/components/ui/alert.tsx";
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table.tsx";
import {Card} from "@/components/ui/card.tsx";
import {FolderCard} from "@/ui/Navigation/Hosts/FolderCard.tsx";
import {getSSHHosts} from "@/ui/main-axios.ts";
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
import {
getOIDCConfig,
getUserList,
makeUserAdmin,
removeAdminStatus,
deleteUser,
deleteAccount
} from "@/ui/main-axios.ts";
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
updatedAt: string;
}
interface SidebarProps {
onSelectView: (view: string) => void;
getView?: () => string;
disabled?: boolean;
isAdmin?: boolean;
username?: string | null;
children?: React.ReactNode;
}
function handleLogout() {
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
window.location.reload();
}
function getCookie(name: string) {
return document.cookie.split('; ').reduce((r, v) => {
const parts = v.split('=');
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
}, "");
}
export function LeftSidebar({
onSelectView,
getView,
disabled,
isAdmin,
username,
children,
}: SidebarProps): React.ReactElement {
const [adminSheetOpen, setAdminSheetOpen] = React.useState(false);
const [deleteAccountOpen, setDeleteAccountOpen] = React.useState(false);
const [deletePassword, setDeletePassword] = React.useState("");
const [deleteLoading, setDeleteLoading] = React.useState(false);
const [deleteError, setDeleteError] = React.useState<string | null>(null);
const [adminCount, setAdminCount] = React.useState(0);
const [users, setUsers] = React.useState<Array<{
id: string;
username: string;
is_admin: boolean;
is_oidc: boolean;
}>>([]);
const [newAdminUsername, setNewAdminUsername] = React.useState("");
const [usersLoading, setUsersLoading] = React.useState(false);
const [makeAdminLoading, setMakeAdminLoading] = React.useState(false);
const [makeAdminError, setMakeAdminError] = React.useState<string | null>(null);
const [makeAdminSuccess, setMakeAdminSuccess] = React.useState<string | null>(null);
const [oidcConfig, setOidcConfig] = React.useState<any>(null);
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(true);
const {tabs: tabList, addTab, setCurrentTab, allSplitScreenTab} = useTabs() as any;
const isSplitScreenActive = Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0;
const sshManagerTab = tabList.find((t) => t.type === 'ssh_manager');
const openSshManagerTab = () => {
if (sshManagerTab || isSplitScreenActive) return;
const id = addTab({type: 'ssh_manager', title: 'SSH Manager'} as any);
setCurrentTab(id);
};
const adminTab = tabList.find((t) => t.type === 'admin');
const openAdminTab = () => {
if (isSplitScreenActive) return;
if (adminTab) {
setCurrentTab(adminTab.id);
return;
}
const id = addTab({type: 'admin', title: 'Admin'} as any);
setCurrentTab(id);
};
const [hosts, setHosts] = useState<SSHHost[]>([]);
const [hostsLoading, setHostsLoading] = useState(false);
const [hostsError, setHostsError] = useState<string | null>(null);
const prevHostsRef = React.useRef<SSHHost[]>([]);
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
React.useEffect(() => {
if (adminSheetOpen) {
const jwt = getCookie("jwt");
if (jwt && isAdmin) {
getOIDCConfig().then(res => {
if (res) {
setOidcConfig(res);
}
}).catch((error) => {
});
fetchUsers();
}
} else {
const jwt = getCookie("jwt");
if (jwt && isAdmin) {
fetchAdminCount();
}
}
}, [adminSheetOpen, isAdmin]);
React.useEffect(() => {
if (!isAdmin) {
setAdminSheetOpen(false);
setUsers([]);
setAdminCount(0);
}
}, [isAdmin]);
const fetchHosts = React.useCallback(async () => {
try {
const newHosts = await getSSHHosts();
const prevHosts = prevHostsRef.current;
const existingHostsMap = new Map(prevHosts.map(h => [h.id, h]));
const newHostsMap = new Map(newHosts.map(h => [h.id, h]));
let hasChanges = false;
if (newHosts.length !== prevHosts.length) {
hasChanges = true;
} else {
for (const [id, newHost] of newHostsMap) {
const existingHost = existingHostsMap.get(id);
if (!existingHost) {
hasChanges = true;
break;
}
if (
newHost.name !== existingHost.name ||
newHost.folder !== existingHost.folder ||
newHost.ip !== existingHost.ip ||
newHost.port !== existingHost.port ||
newHost.username !== existingHost.username ||
newHost.pin !== existingHost.pin ||
newHost.enableTerminal !== existingHost.enableTerminal ||
JSON.stringify(newHost.tags) !== JSON.stringify(existingHost.tags)
) {
hasChanges = true;
break;
}
}
}
if (hasChanges) {
setTimeout(() => {
setHosts(newHosts);
prevHostsRef.current = newHosts;
}, 50);
}
} catch (err: any) {
setHostsError('Failed to load hosts');
}
}, []);
React.useEffect(() => {
fetchHosts();
const interval = setInterval(fetchHosts, 300000); // 5 minutes instead of 10 seconds
return () => clearInterval(interval);
}, [fetchHosts]);
React.useEffect(() => {
const handleHostsChanged = () => {
fetchHosts();
};
window.addEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
return () => window.removeEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
}, [fetchHosts]);
React.useEffect(() => {
const handler = setTimeout(() => setDebouncedSearch(search), 200);
return () => clearTimeout(handler);
}, [search]);
const filteredHosts = React.useMemo(() => {
if (!debouncedSearch.trim()) return hosts;
const q = debouncedSearch.trim().toLowerCase();
return hosts.filter(h => {
const searchableText = [
h.name || '',
h.username,
h.ip,
h.folder || '',
...(h.tags || []),
h.authType,
h.defaultPath || ''
].join(' ').toLowerCase();
return searchableText.includes(q);
});
}, [hosts, debouncedSearch]);
const hostsByFolder = React.useMemo(() => {
const map: Record<string, SSHHost[]> = {};
filteredHosts.forEach(h => {
const folder = h.folder && h.folder.trim() ? h.folder : 'No Folder';
if (!map[folder]) map[folder] = [];
map[folder].push(h);
});
return map;
}, [filteredHosts]);
const sortedFolders = React.useMemo(() => {
const folders = Object.keys(hostsByFolder);
folders.sort((a, b) => {
if (a === 'No Folder') return -1;
if (b === 'No Folder') return 1;
return a.localeCompare(b);
});
return folders;
}, [hostsByFolder]);
const getSortedHosts = React.useCallback((arr: SSHHost[]) => {
const pinned = arr.filter(h => h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
const rest = arr.filter(h => !h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
return [...pinned, ...rest];
}, []);
const handleDeleteAccount = async (e: React.FormEvent) => {
e.preventDefault();
setDeleteLoading(true);
setDeleteError(null);
if (!deletePassword.trim()) {
setDeleteError("Password is required");
setDeleteLoading(false);
return;
}
const jwt = getCookie("jwt");
try {
await deleteAccount(deletePassword);
handleLogout();
} catch (err: any) {
setDeleteError(err?.response?.data?.error || "Failed to delete account");
setDeleteLoading(false);
}
};
const fetchUsers = async () => {
const jwt = getCookie("jwt");
if (!jwt || !isAdmin) {
return;
}
setUsersLoading(true);
try {
const response = await getUserList();
setUsers(response.users);
const adminUsers = response.users.filter((user: any) => user.is_admin);
setAdminCount(adminUsers.length);
} catch (err: any) {
} finally {
setUsersLoading(false);
}
};
const fetchAdminCount = async () => {
const jwt = getCookie("jwt");
if (!jwt || !isAdmin) {
return;
}
try {
const response = await getUserList();
const adminUsers = response.users.filter((user: any) => user.is_admin);
setAdminCount(adminUsers.length);
} catch (err: any) {
}
};
const makeUserAdmin = async (e: React.FormEvent) => {
e.preventDefault();
if (!newAdminUsername.trim()) return;
if (!isAdmin) {
return;
}
setMakeAdminLoading(true);
setMakeAdminError(null);
setMakeAdminSuccess(null);
const jwt = getCookie("jwt");
try {
await makeUserAdmin(newAdminUsername.trim());
setMakeAdminSuccess(`User ${newAdminUsername} is now an admin`);
setNewAdminUsername("");
fetchUsers();
} catch (err: any) {
setMakeAdminError(err?.response?.data?.error || "Failed to make user admin");
} finally {
setMakeAdminLoading(false);
}
};
const removeAdminStatus = async (username: string) => {
if (!confirm(`Are you sure you want to remove admin status from ${username}?`)) return;
if (!isAdmin) {
return;
}
const jwt = getCookie("jwt");
try {
await removeAdminStatus(username);
fetchUsers();
} catch (err: any) {
}
};
const deleteUser = async (username: string) => {
if (!confirm(`Are you sure you want to delete user ${username}? This action cannot be undone.`)) return;
if (!isAdmin) {
return;
}
const jwt = getCookie("jwt");
try {
await deleteUser(username);
fetchUsers();
} catch (err: any) {
}
};
return (
<div className="min-h-svh">
<SidebarProvider open={isSidebarOpen}>
<Sidebar variant="floating" className="">
<SidebarHeader>
<SidebarGroupLabel className="text-lg font-bold text-white">
Termix
<Button
variant="outline"
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="w-[28px] h-[28px] absolute right-5"
>
<Menu className="h-4 w-4"/>
</Button>
</SidebarGroupLabel>
</SidebarHeader>
<Separator className="p-0.25"/>
<SidebarContent>
<SidebarGroup className="!m-0 !p-0 !-mb-2">
<Button className="m-2 flex flex-row font-semibold border-2 !border-[#303032]" variant="outline"
onClick={openSshManagerTab} disabled={!!sshManagerTab || isSplitScreenActive}
title={sshManagerTab ? 'SSH Manager already open' : isSplitScreenActive ? 'Disabled during split screen' : undefined}>
<HardDrive strokeWidth="2.5"/>
Host Manager
</Button>
</SidebarGroup>
<Separator className="p-0.25"/>
<SidebarGroup className="flex flex-col gap-y-2 !-mt-2">
<div className="!bg-[#222225] rounded-lg">
<Input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search hosts by any info..."
className="w-full h-8 text-sm border-2 !bg-[#222225] border-[#303032] rounded-md"
autoComplete="off"
/>
</div>
{hostsError && (
<div className="px-1">
<div
className="text-xs text-red-500 bg-red-500/10 rounded-lg px-2 py-1 border w-full">
{hostsError}
</div>
</div>
)}
{hostsLoading && (
<div className="px-4 pb-2">
<div className="text-xs text-muted-foreground text-center">
Loading hosts...
</div>
</div>
)}
{sortedFolders.map((folder, idx) => (
<FolderCard
key={`folder-${folder}-${hostsByFolder[folder]?.length || 0}`}
folderName={folder}
hosts={getSortedHosts(hostsByFolder[folder])}
isFirst={idx === 0}
isLast={idx === sortedFolders.length - 1}
/>
))}
</SidebarGroup>
</SidebarContent>
<Separator className="p-0.25 mt-1 mb-1"/>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
className="data-[state=open]:opacity-90 w-full"
style={{width: '100%'}}
disabled={disabled}
>
<User2/> {username ? username : 'Signed out'}
<ChevronUp className="ml-auto"/>
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="start"
sideOffset={6}
className="min-w-[var(--radix-popper-anchor-width)] bg-sidebar-accent text-sidebar-accent-foreground border border-border rounded-md shadow-2xl p-1"
>
<DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onClick={() => {
if (isSplitScreenActive) return;
const profileTab = tabList.find((t: any) => t.type === 'profile');
if (profileTab) {
setCurrentTab(profileTab.id);
return;
}
const id = addTab({type: 'profile', title: 'Profile'} as any);
setCurrentTab(id);
}}>
<span>Profile & Security</span>
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onClick={() => {
if (isAdmin) openAdminTab();
}}>
<span>Admin Settings</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onClick={handleLogout}>
<span>Sign out</span>
</DropdownMenuItem>
<DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onClick={() => setDeleteAccountOpen(true)}
disabled={isAdmin && adminCount <= 1}
>
<span
className={isAdmin && adminCount <= 1 ? "text-muted-foreground" : "text-red-400"}>
Delete Account
{isAdmin && adminCount <= 1 && " (Last Admin)"}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
<SidebarInset>
{children}
</SidebarInset>
</SidebarProvider>
{!isSidebarOpen && (
<div
onClick={() => setIsSidebarOpen(true)}
className="absolute top-0 left-0 w-[10px] h-full bg-[#18181b] cursor-pointer z-20 flex items-center justify-center rounded-tr-md rounded-br-md">
<ChevronRight size={10}/>
</div>
)}
{deleteAccountOpen && (
<div
className="fixed inset-0 z-[999999] flex"
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 999999,
pointerEvents: 'auto',
isolation: 'isolate',
transform: 'translateZ(0)',
willChange: 'z-index'
}}
>
<div
className="w-[400px] h-full bg-[#18181b] border-r-2 border-[#303032] flex flex-col shadow-2xl"
style={{
backgroundColor: '#18181b',
boxShadow: '4px 0 20px rgba(0, 0, 0, 0.5)',
zIndex: 9999999,
position: 'relative',
isolation: 'isolate',
transform: 'translateZ(0)'
}}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-[#303032]">
<h2 className="text-lg font-semibold text-white">Delete Account</h2>
<Button
variant="outline"
size="sm"
onClick={() => {
setDeleteAccountOpen(false);
setDeletePassword("");
setDeleteError(null);
}}
className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center"
title="Close Delete Account"
>
<span className="text-lg font-bold leading-none">×</span>
</Button>
</div>
<div className="flex-1 overflow-y-auto p-4">
<div className="space-y-4">
<div className="text-sm text-gray-300">
This action cannot be undone. This will permanently delete your account and all
associated data.
</div>
<Alert variant="destructive">
<AlertTitle>Warning</AlertTitle>
<AlertDescription>
Deleting your account will remove all your data including SSH hosts,
configurations, and settings.
This action is irreversible.
</AlertDescription>
</Alert>
{deleteError && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{deleteError}</AlertDescription>
</Alert>
)}
<form onSubmit={handleDeleteAccount} className="space-y-4">
{isAdmin && adminCount <= 1 && (
<Alert variant="destructive">
<AlertTitle>Cannot Delete Account</AlertTitle>
<AlertDescription>
You are the last admin user. You cannot delete your account as this
would leave the system without any administrators.
Please make another user an admin first, or contact system support.
</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Label htmlFor="delete-password">Confirm Password</Label>
<Input
id="delete-password"
type="password"
value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)}
placeholder="Enter your password to confirm"
required
disabled={isAdmin && adminCount <= 1}
/>
</div>
<div className="flex gap-2">
<Button
type="submit"
variant="destructive"
className="flex-1"
disabled={deleteLoading || !deletePassword.trim() || (isAdmin && adminCount <= 1)}
>
{deleteLoading ? "Deleting..." : "Delete Account"}
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
setDeleteAccountOpen(false);
setDeletePassword("");
setDeleteError(null);
}}
>
Cancel
</Button>
</div>
</form>
</div>
</div>
</div>
<div
className="flex-1"
onClick={() => {
setDeleteAccountOpen(false);
setDeletePassword("");
setDeleteError(null);
}}
style={{cursor: 'pointer'}}
/>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,140 @@
import React from "react";
import {ButtonGroup} from "@/components/ui/button-group.tsx";
import {Button} from "@/components/ui/button.tsx";
import {
Home,
SeparatorVertical,
X,
Terminal as TerminalIcon,
Server as ServerIcon,
Folder as FolderIcon
} from "lucide-react";
interface TabProps {
tabType: string;
title?: string;
isActive?: boolean;
onActivate?: () => void;
onClose?: () => void;
onSplit?: () => void;
canSplit?: boolean;
canClose?: boolean;
disableActivate?: boolean;
disableSplit?: boolean;
disableClose?: boolean;
}
export function Tab({
tabType,
title,
isActive,
onActivate,
onClose,
onSplit,
canSplit = false,
canClose = false,
disableActivate = false,
disableSplit = false,
disableClose = false
}: TabProps): React.ReactElement {
if (tabType === "home") {
return (
<Button
variant="outline"
className={`!px-2 border-1 border-[#303032] ${isActive ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30]' : ''}`}
onClick={onActivate}
disabled={disableActivate}
>
<Home/>
</Button>
);
}
if (tabType === "terminal" || tabType === "server" || tabType === "file_manager") {
const isServer = tabType === 'server';
const isFileManager = tabType === 'file_manager';
return (
<ButtonGroup>
<Button
variant="outline"
className={`!px-2 border-1 border-[#303032] ${isActive ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30]' : ''}`}
onClick={onActivate}
disabled={disableActivate}
>
{isServer ? <ServerIcon className="mr-1 h-4 w-4"/> : isFileManager ?
<FolderIcon className="mr-1 h-4 w-4"/> : <TerminalIcon className="mr-1 h-4 w-4"/>}
{title || (isServer ? 'Server' : isFileManager ? 'file_manager' : 'Terminal')}
</Button>
{canSplit && (
<Button
variant="outline"
className="!px-2 border-1 border-[#303032]"
onClick={onSplit}
disabled={disableSplit}
title={disableSplit ? 'Cannot split this tab' : 'Split'}
>
<SeparatorVertical className="w-[28px] h-[28px]"/>
</Button>
)}
{canClose && (
<Button
variant="outline"
className="!px-2 border-1 border-[#303032]"
onClick={onClose}
disabled={disableClose}
>
<X/>
</Button>
)}
</ButtonGroup>
);
}
if (tabType === "ssh_manager") {
return (
<ButtonGroup>
<Button
variant="outline"
className={`!px-2 border-1 border-[#303032] ${isActive ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30]' : ''}`}
onClick={onActivate}
disabled={disableActivate}
>
{title || "SSH Manager"}
</Button>
<Button
variant="outline"
className="!px-2 border-1 border-[#303032]"
onClick={onClose}
disabled={disableClose}
>
<X/>
</Button>
</ButtonGroup>
);
}
if (tabType === "admin") {
return (
<ButtonGroup>
<Button
variant="outline"
className={`!px-2 border-1 border-[#303032] ${isActive ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30]' : ''}`}
onClick={onActivate}
disabled={disableActivate}
>
{title || "Admin"}
</Button>
<Button
variant="outline"
className="!px-2 border-1 border-[#303032]"
onClick={onClose}
disabled={disableClose}
>
<X/>
</Button>
</ButtonGroup>
);
}
return null;
}

View File

@@ -0,0 +1,133 @@
import React, {createContext, useContext, useState, useRef, type ReactNode} from 'react';
export interface Tab {
id: number;
type: 'home' | 'terminal' | 'ssh_manager' | 'server' | 'admin' | 'file_manager';
title: string;
hostConfig?: any;
terminalRef?: React.RefObject<any>;
}
interface TabContextType {
tabs: Tab[];
currentTab: number | null;
allSplitScreenTab: number[];
addTab: (tab: Omit<Tab, 'id'>) => number;
removeTab: (tabId: number) => void;
setCurrentTab: (tabId: number) => void;
setSplitScreenTab: (tabId: number) => void;
getTab: (tabId: number) => Tab | undefined;
}
const TabContext = createContext<TabContextType | undefined>(undefined);
export function useTabs() {
const context = useContext(TabContext);
if (context === undefined) {
throw new Error('useTabs must be used within a TabProvider');
}
return context;
}
interface TabProviderProps {
children: ReactNode;
}
export function TabProvider({children}: TabProviderProps) {
const [tabs, setTabs] = useState<Tab[]>([
{id: 1, type: 'home', title: 'Home'}
]);
const [currentTab, setCurrentTab] = useState<number>(1);
const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]);
const nextTabId = useRef(2);
function computeUniqueTitle(tabType: Tab['type'], desiredTitle: string | undefined): string {
const defaultTitle = tabType === 'server' ? 'Server' : (tabType === 'file_manager' ? 'File Manager' : 'Terminal');
const baseTitle = (desiredTitle || defaultTitle).trim();
const match = baseTitle.match(/^(.*) \((\d+)\)$/);
const root = match ? match[1] : baseTitle;
const usedNumbers = new Set<number>();
let rootUsed = false;
tabs.forEach(t => {
if (!t.title) return;
if (t.title === root) {
rootUsed = true;
return;
}
const m = t.title.match(new RegExp(`^${root.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&")} \\((\\d+)\\)$`));
if (m) {
const n = parseInt(m[1], 10);
if (!isNaN(n)) usedNumbers.add(n);
}
});
if (!rootUsed) return root;
let n = 2;
while (usedNumbers.has(n)) n += 1;
return `${root} (${n})`;
}
const addTab = (tabData: Omit<Tab, 'id'>): number => {
const id = nextTabId.current++;
const needsUniqueTitle = tabData.type === 'terminal' || tabData.type === 'server' || tabData.type === 'file_manager';
const effectiveTitle = needsUniqueTitle ? computeUniqueTitle(tabData.type, tabData.title) : (tabData.title || '');
const newTab: Tab = {
...tabData,
id,
title: effectiveTitle,
terminalRef: tabData.type === 'terminal' ? React.createRef<any>() : undefined
};
setTabs(prev => [...prev, newTab]);
setCurrentTab(id);
setAllSplitScreenTab(prev => prev.filter(tid => tid !== id));
return id;
};
const removeTab = (tabId: number) => {
const tab = tabs.find(t => t.id === tabId);
if (tab && tab.terminalRef?.current && typeof tab.terminalRef.current.disconnect === "function") {
tab.terminalRef.current.disconnect();
}
setTabs(prev => prev.filter(tab => tab.id !== tabId));
setAllSplitScreenTab(prev => prev.filter(id => id !== tabId));
if (currentTab === tabId) {
const remainingTabs = tabs.filter(tab => tab.id !== tabId);
setCurrentTab(remainingTabs.length > 0 ? remainingTabs[0].id : 1);
}
};
const setSplitScreenTab = (tabId: number) => {
setAllSplitScreenTab(prev => {
if (prev.includes(tabId)) {
return prev.filter(id => id !== tabId);
} else if (prev.length < 3) {
return [...prev, tabId];
}
return prev;
});
};
const getTab = (tabId: number) => {
return tabs.find(tab => tab.id === tabId);
};
const value: TabContextType = {
tabs,
currentTab,
allSplitScreenTab,
addTab,
removeTab,
setCurrentTab,
setSplitScreenTab,
getTab,
};
return (
<TabContext.Provider value={value}>
{children}
</TabContext.Provider>
);
}

View File

@@ -0,0 +1,456 @@
import React, {useState} from "react";
import {useSidebar} from "@/components/ui/sidebar";
import {Button} from "@/components/ui/button.tsx";
import {ChevronDown, ChevronUpIcon, Hammer} from "lucide-react";
import {Tab} from "@/ui/Navigation/Tabs/Tab.tsx";
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion.tsx";
import {Input} from "@/components/ui/input.tsx";
import {Checkbox} from "@/components/ui/checkbox.tsx";
import {Separator} from "@/components/ui/separator.tsx";
interface TopNavbarProps {
isTopbarOpen: boolean;
setIsTopbarOpen: (open: boolean) => void;
}
export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): React.ReactElement {
const {state} = useSidebar();
const {tabs, currentTab, setCurrentTab, setSplitScreenTab, removeTab, allSplitScreenTab} = useTabs() as any;
const leftPosition = state === "collapsed" ? "26px" : "264px";
const [toolsSheetOpen, setToolsSheetOpen] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
const handleTabActivate = (tabId: number) => {
setCurrentTab(tabId);
};
const handleTabSplit = (tabId: number) => {
setSplitScreenTab(tabId);
};
const handleTabClose = (tabId: number) => {
removeTab(tabId);
};
const handleTabToggle = (tabId: number) => {
setSelectedTabIds(prev => prev.includes(tabId) ? prev.filter(id => id !== tabId) : [...prev, tabId]);
};
const handleStartRecording = () => {
setIsRecording(true);
setTimeout(() => {
const input = document.getElementById('ssh-tools-input') as HTMLInputElement;
if (input) input.focus();
}, 100);
};
const handleStopRecording = () => {
setIsRecording(false);
setSelectedTabIds([]);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (selectedTabIds.length === 0) return;
const value = e.currentTarget.value;
let commandToSend = '';
if (e.ctrlKey || e.metaKey) {
if (e.key === 'c') {
commandToSend = '\x03'; // Ctrl+C (SIGINT)
e.preventDefault();
} else if (e.key === 'd') {
commandToSend = '\x04'; // Ctrl+D (EOF)
e.preventDefault();
} else if (e.key === 'l') {
commandToSend = '\x0c'; // Ctrl+L (clear screen)
e.preventDefault();
} else if (e.key === 'u') {
commandToSend = '\x15'; // Ctrl+U (clear line)
e.preventDefault();
} else if (e.key === 'k') {
commandToSend = '\x0b'; // Ctrl+K (clear from cursor to end)
e.preventDefault();
} else if (e.key === 'a') {
commandToSend = '\x01'; // Ctrl+A (move to beginning of line)
e.preventDefault();
} else if (e.key === 'e') {
commandToSend = '\x05'; // Ctrl+E (move to end of line)
e.preventDefault();
} else if (e.key === 'w') {
commandToSend = '\x17'; // Ctrl+W (delete word before cursor)
e.preventDefault();
}
} else if (e.key === 'Enter') {
commandToSend = '\n';
e.preventDefault();
} else if (e.key === 'Backspace') {
commandToSend = '\x08'; // Backspace
e.preventDefault();
} else if (e.key === 'Delete') {
commandToSend = '\x7f'; // Delete
e.preventDefault();
} else if (e.key === 'Tab') {
commandToSend = '\x09'; // Tab
e.preventDefault();
} else if (e.key === 'Escape') {
commandToSend = '\x1b'; // Escape
e.preventDefault();
} else if (e.key === 'ArrowUp') {
commandToSend = '\x1b[A'; // Up arrow
e.preventDefault();
} else if (e.key === 'ArrowDown') {
commandToSend = '\x1b[B'; // Down arrow
e.preventDefault();
} else if (e.key === 'ArrowLeft') {
commandToSend = '\x1b[D'; // Left arrow
e.preventDefault();
} else if (e.key === 'ArrowRight') {
commandToSend = '\x1b[C'; // Right arrow
e.preventDefault();
} else if (e.key === 'Home') {
commandToSend = '\x1b[H'; // Home
e.preventDefault();
} else if (e.key === 'End') {
commandToSend = '\x1b[F'; // End
e.preventDefault();
} else if (e.key === 'PageUp') {
commandToSend = '\x1b[5~'; // Page Up
e.preventDefault();
} else if (e.key === 'PageDown') {
commandToSend = '\x1b[6~'; // Page Down
e.preventDefault();
} else if (e.key === 'Insert') {
commandToSend = '\x1b[2~'; // Insert
e.preventDefault();
} else if (e.key === 'F1') {
commandToSend = '\x1bOP'; // F1
e.preventDefault();
} else if (e.key === 'F2') {
commandToSend = '\x1bOQ'; // F2
e.preventDefault();
} else if (e.key === 'F3') {
commandToSend = '\x1bOR'; // F3
e.preventDefault();
} else if (e.key === 'F4') {
commandToSend = '\x1bOS'; // F4
e.preventDefault();
} else if (e.key === 'F5') {
commandToSend = '\x1b[15~'; // F5
e.preventDefault();
} else if (e.key === 'F6') {
commandToSend = '\x1b[17~'; // F6
e.preventDefault();
} else if (e.key === 'F7') {
commandToSend = '\x1b[18~'; // F7
e.preventDefault();
} else if (e.key === 'F8') {
commandToSend = '\x1b[19~'; // F8
e.preventDefault();
} else if (e.key === 'F9') {
commandToSend = '\x1b[20~'; // F9
e.preventDefault();
} else if (e.key === 'F10') {
commandToSend = '\x1b[21~'; // F10
e.preventDefault();
} else if (e.key === 'F11') {
commandToSend = '\x1b[23~'; // F11
e.preventDefault();
} else if (e.key === 'F12') {
commandToSend = '\x1b[24~'; // F12
e.preventDefault();
}
if (commandToSend) {
selectedTabIds.forEach(tabId => {
const tab = tabs.find((t: any) => t.id === tabId);
if (tab?.terminalRef?.current?.sendInput) {
tab.terminalRef.current.sendInput(commandToSend);
}
});
}
};
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (selectedTabIds.length === 0) return;
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
const char = e.key;
selectedTabIds.forEach(tabId => {
const tab = tabs.find((t: any) => t.id === tabId);
if (tab?.terminalRef?.current?.sendInput) {
tab.terminalRef.current.sendInput(char);
}
});
}
};
const isSplitScreenActive = Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0;
const currentTabObj = tabs.find((t: any) => t.id === currentTab);
const currentTabIsHome = currentTabObj?.type === 'home';
const currentTabIsSshManager = currentTabObj?.type === 'ssh_manager';
const currentTabIsAdmin = currentTabObj?.type === 'admin';
const terminalTabs = tabs.filter((tab: any) => tab.type === 'terminal');
function getCookie(name: string) {
return document.cookie.split('; ').reduce((r, v) => {
const parts = v.split('=');
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
}, "");
}
const updateRightClickCopyPaste = (checked: boolean) => {
document.cookie = `rightClickCopyPaste=${checked}; expires=2147483647; path=/`;
}
return (
<div>
<div
className="fixed z-10 h-[50px] bg-[#18181b] border-2 border-[#303032] rounded-lg transition-all duration-200 ease-linear flex flex-row"
style={{
top: isTopbarOpen ? "0.5rem" : "-3rem",
left: leftPosition,
right: "17px",
position: "fixed",
transform: "none",
margin: "0",
padding: "0"
}}
>
<div
className="h-full p-1 pr-2 border-r-2 border-[#303032] w-[calc(100%-6rem)] flex items-center overflow-x-auto overflow-y-hidden gap-2 thin-scrollbar">
{tabs.map((tab: any) => {
const isActive = tab.id === currentTab;
const isSplit = Array.isArray(allSplitScreenTab) && allSplitScreenTab.includes(tab.id);
const isTerminal = tab.type === 'terminal';
const isServer = tab.type === 'server';
const isFileManager = tab.type === 'file_manager';
const isSshManager = tab.type === 'ssh_manager';
const isAdmin = tab.type === 'admin';
const isSplittable = isTerminal || isServer || isFileManager;
const isSplitButtonDisabled = (isActive && !isSplitScreenActive) || ((allSplitScreenTab?.length || 0) >= 3 && !isSplit);
const disableSplit = !isSplittable || isSplitButtonDisabled || isActive || currentTabIsHome || currentTabIsSshManager || currentTabIsAdmin;
const disableActivate = isSplit || ((tab.type === 'home' || tab.type === 'ssh_manager' || tab.type === 'admin') && isSplitScreenActive);
const disableClose = (isSplitScreenActive && isActive) || isSplit;
return (
<Tab
key={tab.id}
tabType={tab.type}
title={tab.title}
isActive={isActive}
onActivate={() => handleTabActivate(tab.id)}
onClose={isTerminal || isServer || isFileManager || isSshManager || isAdmin ? () => handleTabClose(tab.id) : undefined}
onSplit={isSplittable ? () => handleTabSplit(tab.id) : undefined}
canSplit={isSplittable}
canClose={isTerminal || isServer || isFileManager || isSshManager || isAdmin}
disableActivate={disableActivate}
disableSplit={disableSplit}
disableClose={disableClose}
/>
);
})}
</div>
<div className="flex items-center justify-center gap-2 flex-1 px-2">
<Button
variant="outline"
className="w-[30px] h-[30px]"
title="SSH Tools"
onClick={() => setToolsSheetOpen(true)}
>
<Hammer className="h-4 w-4"/>
</Button>
<Button
variant="outline"
onClick={() => setIsTopbarOpen(false)}
className="w-[30px] h-[30px]"
>
<ChevronUpIcon/>
</Button>
</div>
</div>
{!isTopbarOpen && (
<div
onClick={() => setIsTopbarOpen(true)}
className="absolute top-0 left-0 w-full h-[10px] bg-[#18181b] cursor-pointer z-20 flex items-center justify-center rounded-bl-md rounded-br-md">
<ChevronDown size={10}/>
</div>
)}
{toolsSheetOpen && (
<div
className="fixed inset-0 z-[999999] flex justify-end"
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 999999,
pointerEvents: 'auto',
isolation: 'isolate',
transform: 'translateZ(0)'
}}
>
<div
className="flex-1"
onClick={() => setToolsSheetOpen(false)}
style={{cursor: 'pointer'}}
/>
<div
className="w-[400px] h-full bg-[#18181b] border-l-2 border-[#303032] flex flex-col shadow-2xl"
style={{
backgroundColor: '#18181b',
boxShadow: '-4px 0 20px rgba(0, 0, 0, 0.5)',
zIndex: 999999,
position: 'relative',
isolation: 'isolate',
transform: 'translateZ(0)'
}}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-[#303032]">
<h2 className="text-lg font-semibold text-white">SSH Tools</h2>
<Button
variant="outline"
size="sm"
onClick={() => setToolsSheetOpen(false)}
className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center"
title="Close SSH Tools"
>
<span className="text-lg font-bold leading-none">×</span>
</Button>
</div>
<div className="flex-1 overflow-y-auto p-4">
<div className="space-y-4">
<h1 className="font-semibold">
Key Recording
</h1>
<div className="space-y-4">
<div className="space-y-4">
<div className="flex gap-2">
{!isRecording ? (
<Button
onClick={handleStartRecording}
className="flex-1"
variant="outline"
>
Start Key Recording
</Button>
) : (
<Button
onClick={handleStopRecording}
className="flex-1"
variant="destructive"
>
Stop Key Recording
</Button>
)}
</div>
{isRecording && (
<>
<div className="space-y-2">
<label className="text-sm font-medium text-white">Select
terminals:</label>
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto mt-2">
{terminalTabs.map(tab => (
<Button
key={tab.id}
type="button"
variant="outline"
size="sm"
className={`rounded-full px-3 py-1 text-xs flex items-center gap-1 ${
selectedTabIds.includes(tab.id)
? 'text-white bg-gray-700'
: 'text-gray-500'
}`}
onClick={() => handleTabToggle(tab.id)}
>
{tab.title}
</Button>
))}
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white">Type commands (all
keys supported):</label>
<Input
id="ssh-tools-input"
placeholder="Type here"
onKeyDown={handleKeyDown}
onKeyPress={handleKeyPress}
className="font-mono mt-2"
disabled={selectedTabIds.length === 0}
readOnly
/>
<p className="text-xs text-muted-foreground">
Commands will be sent to {selectedTabIds.length} selected
terminal(s).
</p>
</div>
</>
)}
</div>
</div>
<Separator className="my-4"/>
<h1 className="font-semibold">
Settings
</h1>
<div className="flex items-center space-x-2">
<Checkbox
id="enable-copy-paste"
onCheckedChange={updateRightClickCopyPaste}
defaultChecked={getCookie("rightClickCopyPaste") === "true"}
/>
<label
htmlFor="enable-copy-paste"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-white"
>
Enable rightclick copy/paste
</label>
</div>
<Separator className="my-4"/>
<p className="pt-2 pb-2 text-sm text-gray-500">
Have ideas for what should come next for ssh tools? Share them on{" "}
<a
href="https://github.com/LukeGus/Termix/issues/new"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
GitHub
</a>
!
</p>
</div>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,262 @@
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card.tsx";
import {Key} from "lucide-react";
import React, {useState} from "react";
import {completePasswordReset, initiatePasswordReset, verifyPasswordResetCode} from "@/ui/main-axios.ts";
import {Label} from "@/components/ui/label.tsx";
import {Input} from "@/components/ui/input.tsx";
import {Button} from "@/components/ui/button.tsx";
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
interface PasswordResetProps {
userInfo: {
username: string;
is_admin: boolean;
is_oidc: boolean;
totp_enabled: boolean;
}
}
export function PasswordReset({userInfo}: PasswordResetProps) {
const [error, setError] = useState<string | null>(null);
const [resetStep, setResetStep] = useState<"initiate" | "verify" | "newPassword">("initiate");
const [resetCode, setResetCode] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [tempToken, setTempToken] = useState("");
const [resetLoading, setResetLoading] = useState(false);
const [resetSuccess, setResetSuccess] = useState(false);
async function handleInitiatePasswordReset() {
setError(null);
setResetLoading(true);
try {
const result = await initiatePasswordReset(userInfo.username);
setResetStep("verify");
setError(null);
} catch (err: any) {
setError(err?.response?.data?.error || err?.message || "Failed to initiate password reset");
} finally {
setResetLoading(false);
}
}
function resetPasswordState() {
setResetStep("initiate");
setResetCode("");
setNewPassword("");
setConfirmPassword("");
setTempToken("");
setError(null);
setResetSuccess(false);
}
async function handleVerifyResetCode() {
setError(null);
setResetLoading(true);
try {
const response = await verifyPasswordResetCode(userInfo.username, resetCode);
setTempToken(response.tempToken);
setResetStep("newPassword");
setError(null);
} catch (err: any) {
setError(err?.response?.data?.error || "Failed to verify reset code");
} finally {
setResetLoading(false);
}
}
async function handleCompletePasswordReset() {
setError(null);
setResetLoading(true);
if (newPassword !== confirmPassword) {
setError("Passwords do not match");
setResetLoading(false);
return;
}
if (newPassword.length < 6) {
setError("Password must be at least 6 characters long");
setResetLoading(false);
return;
}
try {
await completePasswordReset(userInfo.username, tempToken, newPassword);
setResetStep("initiate");
setResetCode("");
setNewPassword("");
setConfirmPassword("");
setTempToken("");
setError(null);
setResetSuccess(true);
} catch (err: any) {
setError(err?.response?.data?.error || "Failed to complete password reset");
} finally {
setResetLoading(false);
}
}
const Spinner = (
<svg className="animate-spin mr-2 h-4 w-4 text-white inline-block" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
</svg>
);
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Key className="w-5 h-5"/>
Password
</CardTitle>
<CardDescription>
Change your account password
</CardDescription>
</CardHeader>
<CardContent>
<>
{resetStep === "initiate" && !resetSuccess && (
<>
<div className="flex flex-col gap-4">
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading || !userInfo.username.trim()}
onClick={handleInitiatePasswordReset}
>
{resetLoading ? Spinner : "Send Reset Code"}
</Button>
</div>
</>
)}
{resetStep === "verify" && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>Enter the 6-digit code from the docker container logs for
user: <strong>{userInfo.username}</strong></p>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="reset-code">Reset Code</Label>
<Input
id="reset-code"
type="text"
required
maxLength={6}
className="h-11 text-base text-center text-lg tracking-widest"
value={resetCode}
onChange={e => setResetCode(e.target.value.replace(/\D/g, ''))}
disabled={resetLoading}
placeholder="000000"
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading || resetCode.length !== 6}
onClick={handleVerifyResetCode}
>
{resetLoading ? Spinner : "Verify Code"}
</Button>
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading}
onClick={() => {
setResetStep("initiate");
setResetCode("");
}}
>
Back
</Button>
</div>
</>
)}
{resetSuccess && (
<>
<Alert className="">
<AlertTitle>Success!</AlertTitle>
<AlertDescription>
Your password has been successfully reset! You can now log in
with your new password.
</AlertDescription>
</Alert>
</>
)}
{resetStep === "newPassword" && !resetSuccess && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>Enter your new password for
user: <strong>{userInfo.username}</strong></p>
</div>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-2">
<Label htmlFor="new-password">New Password</Label>
<Input
id="new-password"
type="password"
required
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
disabled={resetLoading}
autoComplete="new-password"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="confirm-password">Confirm Password</Label>
<Input
id="confirm-password"
type="password"
required
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
disabled={resetLoading}
autoComplete="new-password"
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading || !newPassword || !confirmPassword}
onClick={handleCompletePasswordReset}
>
{resetLoading ? Spinner : "Reset Password"}
</Button>
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading}
onClick={() => {
setResetStep("verify");
setNewPassword("");
setConfirmPassword("");
}}
>
Back
</Button>
</div>
</>
)}
{error && (
<Alert variant="destructive" className="mt-4">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</>
</CardContent>
</Card>
)
}

437
src/ui/User/TOTPSetup.tsx Normal file
View File

@@ -0,0 +1,437 @@
import React, { useState } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card.tsx";
import { Button } from "@/components/ui/button.tsx";
import { Input } from "@/components/ui/input.tsx";
import { Label } from "@/components/ui/label.tsx";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs.tsx";
import { Shield, Copy, Download, AlertCircle, CheckCircle2 } from "lucide-react";
import { setupTOTP, enableTOTP, disableTOTP, generateBackupCodes } from "@/ui/main-axios.ts";
import { toast } from "sonner";
interface TOTPSetupProps {
isEnabled: boolean;
onStatusChange?: (enabled: boolean) => void;
}
export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSetupProps) {
const [isEnabled, setIsEnabled] = useState(initialEnabled);
const [isSettingUp, setIsSettingUp] = useState(false);
const [setupStep, setSetupStep] = useState<"init" | "qr" | "verify" | "backup">("init");
const [qrCode, setQrCode] = useState("");
const [secret, setSecret] = useState("");
const [verificationCode, setVerificationCode] = useState("");
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [password, setPassword] = useState("");
const [disableCode, setDisableCode] = useState("");
const handleSetupStart = async () => {
setError(null);
setLoading(true);
try {
const response = await setupTOTP();
setQrCode(response.qr_code);
setSecret(response.secret);
setSetupStep("qr");
setIsSettingUp(true);
} catch (err: any) {
setError(err?.response?.data?.error || "Failed to start TOTP setup");
} finally {
setLoading(false);
}
};
const handleVerifyCode = async () => {
if (verificationCode.length !== 6) {
setError("Please enter a 6-digit code");
return;
}
setError(null);
setLoading(true);
try {
const response = await enableTOTP(verificationCode);
setBackupCodes(response.backup_codes);
setSetupStep("backup");
toast.success("Two-factor authentication enabled successfully!");
} catch (err: any) {
setError(err?.response?.data?.error || "Invalid verification code");
} finally {
setLoading(false);
}
};
const handleDisable = async () => {
setError(null);
setLoading(true);
try {
await disableTOTP(password || undefined, disableCode || undefined);
setIsEnabled(false);
setIsSettingUp(false);
setSetupStep("init");
setPassword("");
setDisableCode("");
onStatusChange?.(false);
toast.success("Two-factor authentication disabled");
} catch (err: any) {
setError(err?.response?.data?.error || "Failed to disable TOTP");
} finally {
setLoading(false);
}
};
const handleGenerateNewBackupCodes = async () => {
setError(null);
setLoading(true);
try {
const response = await generateBackupCodes(password || undefined, disableCode || undefined);
setBackupCodes(response.backup_codes);
toast.success("New backup codes generated");
} catch (err: any) {
setError(err?.response?.data?.error || "Failed to generate backup codes");
} finally {
setLoading(false);
}
};
const copyToClipboard = (text: string, label: string) => {
navigator.clipboard.writeText(text);
toast.success(`${label} copied to clipboard`);
};
const downloadBackupCodes = () => {
const content = `Termix Two-Factor Authentication Backup Codes\n` +
`Generated: ${new Date().toISOString()}\n\n` +
`Keep these codes in a safe place. Each code can only be used once.\n\n` +
backupCodes.map((code, i) => `${i + 1}. ${code}`).join('\n');
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'termix-backup-codes.txt';
a.click();
URL.revokeObjectURL(url);
toast.success("Backup codes downloaded");
};
const handleComplete = () => {
setIsEnabled(true);
setIsSettingUp(false);
setSetupStep("init");
setVerificationCode("");
onStatusChange?.(true);
};
if (isEnabled && !isSettingUp) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="w-5 h-5" />
Two-Factor Authentication
</CardTitle>
<CardDescription>
Your account is protected with two-factor authentication
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Alert>
<CheckCircle2 className="h-4 w-4" />
<AlertTitle>Enabled</AlertTitle>
<AlertDescription>
Two-factor authentication is currently active on your account
</AlertDescription>
</Alert>
<Tabs defaultValue="disable" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="disable">Disable 2FA</TabsTrigger>
<TabsTrigger value="backup">Backup Codes</TabsTrigger>
</TabsList>
<TabsContent value="disable" className="space-y-4">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Warning</AlertTitle>
<AlertDescription>
Disabling two-factor authentication will make your account less secure
</AlertDescription>
</Alert>
<div className="space-y-2">
<Label htmlFor="disable-password">Password or TOTP Code</Label>
<Input
id="disable-password"
type="password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<p className="text-sm text-muted-foreground">Or</p>
<Input
id="disable-code"
type="text"
placeholder="6-digit TOTP code"
maxLength={6}
value={disableCode}
onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, ''))}
/>
</div>
<Button
variant="destructive"
onClick={handleDisable}
disabled={loading || (!password && !disableCode)}
>
Disable Two-Factor Authentication
</Button>
</TabsContent>
<TabsContent value="backup" className="space-y-4">
<p className="text-sm text-muted-foreground">
Generate new backup codes if you've lost your existing ones
</p>
<div className="space-y-2">
<Label htmlFor="backup-password">Password or TOTP Code</Label>
<Input
id="backup-password"
type="password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<p className="text-sm text-muted-foreground">Or</p>
<Input
id="backup-code"
type="text"
placeholder="6-digit TOTP code"
maxLength={6}
value={disableCode}
onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, ''))}
/>
</div>
<Button
onClick={handleGenerateNewBackupCodes}
disabled={loading || (!password && !disableCode)}
>
Generate New Backup Codes
</Button>
{backupCodes.length > 0 && (
<div className="space-y-2 mt-4">
<div className="flex justify-between items-center">
<Label>Your Backup Codes</Label>
<Button
size="sm"
variant="outline"
onClick={downloadBackupCodes}
>
<Download className="w-4 h-4 mr-2" />
Download
</Button>
</div>
<div className="grid grid-cols-2 gap-2 p-4 bg-muted rounded-lg font-mono text-sm">
{backupCodes.map((code, i) => (
<div key={i}>{code}</div>
))}
</div>
</div>
)}
</TabsContent>
</Tabs>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
);
}
if (setupStep === "qr") {
return (
<Card>
<CardHeader>
<CardTitle>Set Up Two-Factor Authentication</CardTitle>
<CardDescription>
Step 1: Scan the QR code with your authenticator app
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex justify-center">
<img src={qrCode} alt="TOTP QR Code" className="w-64 h-64" />
</div>
<div className="space-y-2">
<Label>Manual Entry Code</Label>
<div className="flex gap-2">
<Input
value={secret}
readOnly
className="font-mono text-sm"
/>
<Button
size="default"
variant="outline"
onClick={() => copyToClipboard(secret, "Secret key")}
>
<Copy className="w-4 h-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
If you can't scan the QR code, enter this code manually in your authenticator app
</p>
</div>
<Button onClick={() => setSetupStep("verify")} className="w-full">
Next: Verify Code
</Button>
</CardContent>
</Card>
);
}
if (setupStep === "verify") {
return (
<Card>
<CardHeader>
<CardTitle>Verify Your Authenticator</CardTitle>
<CardDescription>
Step 2: Enter the 6-digit code from your authenticator app
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="verify-code">Verification Code</Label>
<Input
id="verify-code"
type="text"
placeholder="000000"
maxLength={6}
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value.replace(/\D/g, ''))}
className="text-center text-2xl tracking-widest font-mono"
/>
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setSetupStep("qr")}
disabled={loading}
>
Back
</Button>
<Button
onClick={handleVerifyCode}
disabled={loading || verificationCode.length !== 6}
className="flex-1"
>
{loading ? "Verifying..." : "Verify and Enable"}
</Button>
</div>
</CardContent>
</Card>
);
}
if (setupStep === "backup") {
return (
<Card>
<CardHeader>
<CardTitle>Save Your Backup Codes</CardTitle>
<CardDescription>
Step 3: Store these codes in a safe place
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertTitle>Important</AlertTitle>
<AlertDescription>
Save these backup codes in a secure location. You can use them to access your account if you lose your authenticator device.
</AlertDescription>
</Alert>
<div className="space-y-2">
<div className="flex justify-between items-center">
<Label>Your Backup Codes</Label>
<Button
size="sm"
variant="outline"
onClick={downloadBackupCodes}
>
<Download className="w-4 h-4 mr-2" />
Download
</Button>
</div>
<div className="grid grid-cols-2 gap-2 p-4 bg-muted rounded-lg font-mono text-sm">
{backupCodes.map((code, i) => (
<div key={i} className="flex items-center gap-2">
<span className="text-muted-foreground">{i + 1}.</span>
<span>{code}</span>
</div>
))}
</div>
</div>
<Button onClick={handleComplete} className="w-full">
Complete Setup
</Button>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="w-5 h-5" />
Two-Factor Authentication
</CardTitle>
<CardDescription>
Add an extra layer of security to your account
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertTitle>Not Enabled</AlertTitle>
<AlertDescription>
Two-factor authentication adds an extra layer of security by requiring a code from your authenticator app when signing in.
</AlertDescription>
</Alert>
<Button onClick={handleSetupStart} disabled={loading} className="w-full">
{loading ? "Setting up..." : "Enable Two-Factor Authentication"}
</Button>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
);
}

166
src/ui/User/UserProfile.tsx Normal file
View File

@@ -0,0 +1,166 @@
import React, {useState, useEffect} from "react";
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card.tsx";
import {Button} from "@/components/ui/button.tsx";
import {Input} from "@/components/ui/input.tsx";
import {Label} from "@/components/ui/label.tsx";
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
import {User, Shield, Key, AlertCircle} from "lucide-react";
import {TOTPSetup} from "@/ui/User/TOTPSetup.tsx";
import {getUserInfo} from "@/ui/main-axios.ts";
import {toast} from "sonner";
import {PasswordReset} from "@/ui/User/PasswordReset.tsx";
interface UserProfileProps {
isTopbarOpen?: boolean;
}
export function UserProfile({isTopbarOpen = true}: UserProfileProps) {
const [userInfo, setUserInfo] = useState<{
username: string;
is_admin: boolean;
is_oidc: boolean;
totp_enabled: boolean;
} | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchUserInfo();
}, []);
const fetchUserInfo = async () => {
setLoading(true);
setError(null);
try {
const info = await getUserInfo();
setUserInfo({
username: info.username,
is_admin: info.is_admin,
is_oidc: info.is_oidc,
totp_enabled: info.totp_enabled || false
});
} catch (err: any) {
setError(err?.response?.data?.error || "Failed to load user information");
} finally {
setLoading(false);
}
};
const handleTOTPStatusChange = (enabled: boolean) => {
if (userInfo) {
setUserInfo({...userInfo, totp_enabled: enabled});
}
};
if (loading) {
return (
<div className="container max-w-4xl mx-auto p-6">
<Card>
<CardContent className="p-12 text-center">
<div className="animate-pulse">Loading user profile...</div>
</CardContent>
</Card>
</div>
);
}
if (error || !userInfo) {
return (
<div className="container max-w-4xl mx-auto p-6">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4"/>
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error || "Failed to load user profile"}</AlertDescription>
</Alert>
</div>
);
}
return (
<div className="container max-w-4xl mx-auto p-6 overflow-y-auto" style={{
marginTop: isTopbarOpen ? '60px' : '0',
transition: 'margin-top 0.3s ease',
maxHeight: 'calc(100vh - 60px)'
}}>
<div className="mb-6">
<h1 className="text-3xl font-bold">User Profile</h1>
<p className="text-muted-foreground mt-2">Manage your account settings and security</p>
</div>
<Tabs defaultValue="profile" className="space-y-4">
<TabsList>
<TabsTrigger value="profile" className="flex items-center gap-2">
<User className="w-4 h-4"/>
Profile
</TabsTrigger>
{!userInfo.is_oidc && (
<TabsTrigger value="security" className="flex items-center gap-2">
<Shield className="w-4 h-4"/>
Security
</TabsTrigger>
)}
</TabsList>
<TabsContent value="profile" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Account Information</CardTitle>
<CardDescription>Your account details and settings</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Username</Label>
<p className="text-lg font-medium mt-1">{userInfo.username}</p>
</div>
<div>
<Label>Account Type</Label>
<p className="text-lg font-medium mt-1">
{userInfo.is_admin ? "Administrator" : "User"}
</p>
</div>
<div>
<Label>Authentication Method</Label>
<p className="text-lg font-medium mt-1">
{userInfo.is_oidc ? "External (OIDC)" : "Local"}
</p>
</div>
<div>
<Label>Two-Factor Authentication</Label>
<p className="text-lg font-medium mt-1">
{userInfo.is_oidc ? (
<span className="text-muted-foreground">Locked (OIDC Auth)</span>
) : (
userInfo.totp_enabled ? (
<span className="text-green-600 flex items-center gap-1">
<Shield className="w-4 h-4"/>
Enabled
</span>
) : (
<span className="text-muted-foreground">Disabled</span>
)
)}
</p>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="security" className="space-y-4">
<TOTPSetup
isEnabled={userInfo.totp_enabled}
onStatusChange={handleTOTPStatusChange}
/>
{!userInfo.is_oidc && (
<PasswordReset
userInfo={userInfo}
/>
)}
</TabsContent>
</Tabs>
</div>
);
}

1003
src/ui/main-axios.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -6,16 +6,12 @@
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting - Made extremely permissive */
"strict": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
@@ -31,8 +27,6 @@
"allowUnreachableCode": true,
"noImplicitOverride": false,
"noEmitOnError": false,
/* shadcn */
"baseUrl": ".",
"paths": {
"@/*": [