mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
release-2.5.1 (#1067)
* chore(deps): bump node from 24-slim to 26-slim in /docker in the docker-major-updates group (#1021) * chore: fix release workflow to merge docs branch * fix: svg donation generator push fail * fix: svg donation generator push fail * Update termix.rb * fix: svg donation generator push fail * chore: move donation badge to badges branch to avoid ruleset conflicts * chore: remove unneeded token from donation badge workflow * chore: debug donation badge commit step * fix: escape < character in donation SVG * fix: point donation badge to badges branch * chore: remove unused donation badge svg from main * Add Rack Genius logo to README Added Rack Genius logo to the README. * chore: improve donation goal svg generator to include stablecoins * chore: donation goal generator syntax error * chore: donation goal generator incorrect docs url usage * chore(deps): bump node in /docker in the docker-major-updates group Bumps the docker-major-updates group in /docker with 1 update: node. Updates `node` from 24-slim to 26-slim --- updated-dependencies: - dependency-name: node dependency-version: 26-slim dependency-type: direct:production dependency-group: docker-major-updates ... Signed-off-by: dependabot[bot] <support@github.com> --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump the dev-patch-updates group with 24 updates (#1023) * chore: fix release workflow to merge docs branch * fix: svg donation generator push fail * fix: svg donation generator push fail * Update termix.rb * fix: svg donation generator push fail * chore: move donation badge to badges branch to avoid ruleset conflicts * chore: remove unneeded token from donation badge workflow * chore: debug donation badge commit step * fix: escape < character in donation SVG * fix: point donation badge to badges branch * chore: remove unused donation badge svg from main * Add Rack Genius logo to README Added Rack Genius logo to the README. * chore: improve donation goal svg generator to include stablecoins * chore: donation goal generator syntax error * chore: donation goal generator incorrect docs url usage * chore(deps-dev): bump the dev-patch-updates group with 24 updates Bumps the dev-patch-updates group with 24 updates: | Package | From | To | | --- | --- | --- | | [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.5.1` | `2.5.2` | | [@codemirror/commands](https://github.com/codemirror/commands) | `6.10.3` | `6.10.4` | | [@codemirror/view](https://github.com/codemirror/view) | `6.43.1` | `6.43.5` | | [@radix-ui/react-accordion](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/accordion) | `1.2.14` | `1.2.15` | | [@radix-ui/react-alert-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/alert-dialog) | `1.1.17` | `1.1.18` | | [@radix-ui/react-checkbox](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/checkbox) | `1.3.5` | `1.3.6` | | [@radix-ui/react-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dialog) | `1.1.17` | `1.1.18` | | [@radix-ui/react-dropdown-menu](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dropdown-menu) | `2.1.18` | `2.1.19` | | [@radix-ui/react-label](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/label) | `2.1.10` | `2.1.11` | | [@radix-ui/react-popover](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/popover) | `1.1.17` | `1.1.18` | | [@radix-ui/react-progress](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/progress) | `1.1.10` | `1.1.11` | | [@radix-ui/react-scroll-area](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/scroll-area) | `1.2.12` | `1.2.13` | | [@radix-ui/react-select](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/select) | `2.3.1` | `2.3.2` | | [@radix-ui/react-separator](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/separator) | `1.1.10` | `1.1.11` | | [@radix-ui/react-slider](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slider) | `1.4.1` | `1.4.2` | | [@radix-ui/react-switch](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/switch) | `1.3.1` | `1.3.2` | | [@radix-ui/react-tabs](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tabs) | `1.1.15` | `1.1.16` | | [@radix-ui/react-tooltip](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tooltip) | `1.2.10` | `1.2.11` | | [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.3.1` | `4.3.2` | | [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.0.2` | `6.0.3` | | [i18next](https://github.com/i18next/i18next) | `26.3.1` | `26.3.4` | | [radix-ui](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/radix-ui) | `1.6.0` | `1.6.1` | | [sharp](https://github.com/lovell/sharp) | `0.35.2` | `0.35.3` | | [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.1` | `4.3.2` | Updates `@biomejs/biome` from 2.5.1 to 2.5.2 - [Release notes](https://github.com/biomejs/biome/releases) - [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md) - [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.5.2/packages/@biomejs/biome) Updates `@codemirror/commands` from 6.10.3 to 6.10.4 - [Changelog](https://github.com/codemirror/commands/blob/main/CHANGELOG.md) - [Commits](https://github.com/codemirror/commands/commits) Updates `@codemirror/view` from 6.43.1 to 6.43.5 - [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md) - [Commits](https://github.com/codemirror/view/commits) Updates `@radix-ui/react-accordion` from 1.2.14 to 1.2.15 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/accordion/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/accordion) Updates `@radix-ui/react-alert-dialog` from 1.1.17 to 1.1.18 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/alert-dialog/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/alert-dialog) Updates `@radix-ui/react-checkbox` from 1.3.5 to 1.3.6 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/checkbox/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/checkbox) Updates `@radix-ui/react-dialog` from 1.1.17 to 1.1.18 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dialog) Updates `@radix-ui/react-dropdown-menu` from 2.1.18 to 2.1.19 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dropdown-menu/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dropdown-menu) Updates `@radix-ui/react-label` from 2.1.10 to 2.1.11 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/label/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/label) Updates `@radix-ui/react-popover` from 1.1.17 to 1.1.18 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/popover/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/popover) Updates `@radix-ui/react-progress` from 1.1.10 to 1.1.11 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/progress/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/progress) Updates `@radix-ui/react-scroll-area` from 1.2.12 to 1.2.13 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/scroll-area/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/scroll-area) Updates `@radix-ui/react-select` from 2.3.1 to 2.3.2 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/select/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/select) Updates `@radix-ui/react-separator` from 1.1.10 to 1.1.11 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/separator/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/separator) Updates `@radix-ui/react-slider` from 1.4.1 to 1.4.2 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slider/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slider) Updates `@radix-ui/react-switch` from 1.3.1 to 1.3.2 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/switch/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/switch) Updates `@radix-ui/react-tabs` from 1.1.15 to 1.1.16 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tabs/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tabs) Updates `@radix-ui/react-tooltip` from 1.2.10 to 1.2.11 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tooltip/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tooltip) Updates `@tailwindcss/vite` from 4.3.1 to 4.3.2 - [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.3.2/packages/@tailwindcss-vite) Updates `@vitejs/plugin-react` from 6.0.2 to 6.0.3 - [Release notes](https://github.com/vitejs/vite-plugin-react/releases) - [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.0.3/packages/plugin-react) Updates `i18next` from 26.3.1 to 26.3.4 - [Release notes](https://github.com/i18next/i18next/releases) - [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md) - [Commits](https://github.com/i18next/i18next/compare/v26.3.1...v26.3.4) Updates `radix-ui` from 1.6.0 to 1.6.1 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/radix-ui/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/radix-ui) Updates `sharp` from 0.35.2 to 0.35.3 - [Release notes](https://github.com/lovell/sharp/releases) - [Commits](https://github.com/lovell/sharp/compare/v0.35.2...v0.35.3) Updates `tailwindcss` from 4.3.1 to 4.3.2 - [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.3.2/packages/tailwindcss) --- updated-dependencies: - dependency-name: "@biomejs/biome" dependency-version: 2.5.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@codemirror/commands" dependency-version: 6.10.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@codemirror/view" dependency-version: 6.43.5 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-accordion" dependency-version: 1.2.15 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-alert-dialog" dependency-version: 1.1.18 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-checkbox" dependency-version: 1.3.6 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-dialog" dependency-version: 1.1.18 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-dropdown-menu" dependency-version: 2.1.19 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-label" dependency-version: 2.1.11 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-popover" dependency-version: 1.1.18 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-progress" dependency-version: 1.1.11 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-scroll-area" dependency-version: 1.2.13 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-select" dependency-version: 2.3.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-separator" dependency-version: 1.1.11 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-slider" dependency-version: 1.4.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-switch" dependency-version: 1.3.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-tabs" dependency-version: 1.1.16 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-tooltip" dependency-version: 1.2.11 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@tailwindcss/vite" dependency-version: 4.3.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@vitejs/plugin-react" dependency-version: 6.0.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: i18next dependency-version: 26.3.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: radix-ui dependency-version: 1.6.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: sharp dependency-version: 0.35.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: tailwindcss dependency-version: 4.3.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates ... Signed-off-by: dependabot[bot] <support@github.com> --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the prod-patch-updates group with 2 updates (#1025) * chore: fix release workflow to merge docs branch * fix: svg donation generator push fail * fix: svg donation generator push fail * Update termix.rb * fix: svg donation generator push fail * chore: move donation badge to badges branch to avoid ruleset conflicts * chore: remove unneeded token from donation badge workflow * chore: debug donation badge commit step * fix: escape < character in donation SVG * fix: point donation badge to badges branch * chore: remove unused donation badge svg from main * Add Rack Genius logo to README Added Rack Genius logo to the README. * chore: improve donation goal svg generator to include stablecoins * chore: donation goal generator syntax error * chore: donation goal generator incorrect docs url usage * chore(deps): bump the prod-patch-updates group with 2 updates Bumps the prod-patch-updates group with 2 updates: [axios](https://github.com/axios/axios) and [nanoid](https://github.com/ai/nanoid). Updates `axios` from 1.18.0 to 1.18.1 - [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.18.0...v1.18.1) Updates `nanoid` from 5.1.15 to 5.1.16 - [Release notes](https://github.com/ai/nanoid/releases) - [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md) - [Commits](https://github.com/ai/nanoid/compare/5.1.15...5.1.16) --- updated-dependencies: - dependency-name: axios dependency-version: 1.18.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates - dependency-name: nanoid dependency-version: 5.1.16 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates ... Signed-off-by: dependabot[bot] <support@github.com> --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the prod-minor-updates group with 3 updates (#1026) * chore: fix release workflow to merge docs branch * fix: svg donation generator push fail * fix: svg donation generator push fail * Update termix.rb * fix: svg donation generator push fail * chore: move donation badge to badges branch to avoid ruleset conflicts * chore: remove unneeded token from donation badge workflow * chore: debug donation badge commit step * fix: escape < character in donation SVG * fix: point donation badge to badges branch * chore: remove unused donation badge svg from main * Add Rack Genius logo to README Added Rack Genius logo to the README. * chore: improve donation goal svg generator to include stablecoins * chore: donation goal generator syntax error * chore: donation goal generator incorrect docs url usage * chore(deps): bump the prod-minor-updates group with 3 updates Bumps the prod-minor-updates group with 3 updates: [js-yaml](https://github.com/nodeca/js-yaml), [motion](https://github.com/motiondivision/motion) and [undici](https://github.com/nodejs/undici). Updates `js-yaml` from 5.0.0 to 5.2.1 - [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/5.0.0...5.2.1) Updates `motion` from 12.40.0 to 12.42.2 - [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md) - [Commits](https://github.com/motiondivision/motion/compare/v12.40.0...v12.42.2) Updates `undici` from 8.5.0 to 8.7.0 - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v8.5.0...v8.7.0) --- updated-dependencies: - dependency-name: js-yaml dependency-version: 5.2.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: motion dependency-version: 12.42.2 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: undici dependency-version: 8.7.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates ... Signed-off-by: dependabot[bot] <support@github.com> --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump electron from 42.4.1 to 43.0.0 in the major-updates group (#1027) * chore: fix release workflow to merge docs branch * fix: svg donation generator push fail * fix: svg donation generator push fail * Update termix.rb * fix: svg donation generator push fail * chore: move donation badge to badges branch to avoid ruleset conflicts * chore: remove unneeded token from donation badge workflow * chore: debug donation badge commit step * fix: escape < character in donation SVG * fix: point donation badge to badges branch * chore: remove unused donation badge svg from main * Add Rack Genius logo to README Added Rack Genius logo to the README. * chore: improve donation goal svg generator to include stablecoins * chore: donation goal generator syntax error * chore: donation goal generator incorrect docs url usage * chore(deps-dev): bump electron in the major-updates group Bumps the major-updates group with 1 update: [electron](https://github.com/electron/electron). Updates `electron` from 42.4.1 to 43.0.0 - [Release notes](https://github.com/electron/electron/releases) - [Commits](https://github.com/electron/electron/compare/v42.4.1...v43.0.0) --- updated-dependencies: - dependency-name: electron dependency-version: 43.0.0 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates ... Signed-off-by: dependabot[bot] <support@github.com> --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Fix MC syntax highlighting artifacts (#996) * Filter dashboard status hosts (#997) * Persist dashboard service link changes (#999) * Fix snippet text overflow (#1000) * Persist remote desktop credential auth (#1001) * Guard language switching failures (#1002) * Resolve tunnel source credentials (#1003) * Support Vault auth for monitors (#1004) * Fix Windows file delete command (#1005) * Fix release artifact checkout ref (#1006) * Fix command palette escape in fullscreen (#1008) * Fix alerts and audit log data normalization (#1010) * Fix macOS VNC protocol negotiation (#1012) * Fix port knocking before SSH connect (#1013) * Allow Escape to close link confirmation (#1014) * Prevent Electron modifier wheel zoom (#1016) * Fix credential auth optional password (#1009) * Retry transient terminal DNS lookups (#1011) * Retry transient terminal DNS lookups * Apply DNS retry to SSH entry points * Fix OIDC redirect forwarded port handling (#1007) * Preserve recent open tabs on startup (#1015) * Fix fish prompt OSC highlighting (#998) * Fix terminal font selection (#1018) * fix: font legibility (#1019) * chore: fix release workflow to merge docs branch * fix: svg donation generator push fail * fix: svg donation generator push fail * Update termix.rb * fix: svg donation generator push fail * chore: move donation badge to badges branch to avoid ruleset conflicts * chore: remove unneeded token from donation badge workflow * chore: debug donation badge commit step * fix: escape < character in donation SVG * fix: point donation badge to badges branch * chore: remove unused donation badge svg from main * Add Rack Genius logo to README Added Rack Genius logo to the README. * chore: improve donation goal svg generator to include stablecoins * chore: donation goal generator syntax error * chore: donation goal generator incorrect docs url usage * fix: font legibility Text was entirely unreadable in places for me. Especially with themes like Catppuccin. The muted-foreground text and the tags too similiar to the background. --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: russell <git@0896c69e.com> * fix(file-manager): chunked uploads fail with 'Expected multipart/form-data request' (#1020) * chore: fix release workflow to merge docs branch * fix: svg donation generator push fail * fix: svg donation generator push fail * Update termix.rb * fix: svg donation generator push fail * chore: move donation badge to badges branch to avoid ruleset conflicts * chore: remove unneeded token from donation badge workflow * chore: debug donation badge commit step * fix: escape < character in donation SVG * fix: point donation badge to badges branch * chore: remove unused donation badge svg from main * Add Rack Genius logo to README Added Rack Genius logo to the README. * chore: improve donation goal svg generator to include stablecoins * chore: donation goal generator syntax error * chore: donation goal generator incorrect docs url usage * fix(file-manager): use postForm for chunked uploads so multipart content-type is sent The fileManagerApi axios instance defaults to Content-Type: application/json. Axios 1.x's default transformRequest converts a FormData body to JSON whenever the request content type is application/json, so every chunk POSTed to /ssh/uploadFileChunk arrived as a JSON body like {"chunk":{}} and the backend rejected it with 400 'Expected multipart/form-data request'. This breaks all uploads of files larger than the 1.5 GiB chunking threshold. The non-chunked path already uses postForm for /ssh/uploadFileStream; use it for the chunk path too so axios keeps the FormData intact and the browser sets the multipart boundary. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> * feat: implement OIDC back-channel logout support with session management (#1028) * feat: implement OIDC back-channel logout support with session management * Fix OIDC back-channel logout handling * Require logout token replay identifiers --------- Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> * Add API key host enrollment endpoint (#1029) * Fix tmux detection for non-POSIX shells (#1030) * Fix OPKSSH js-yaml ESM import (#1031) * Fix Android Vietnamese IME input (#1032) * Fix Firefox RDP clipboard paste (#1033) * Fix Proxmox discovery over HTTPS (#1041) * Fix external editor actions in file preview (#1042) * Allow pinned hosts with name sorting (#1043) * Fix Firefox desktop OIDC callback (#1044) * feat(session): add recording and replay (#1049) * Fix status checks through jump hosts (#1045) * Add terminal font size shortcuts (#1047) * feat: add Open File Manager to tab right-click menu (#1051) Co-authored-by: SankeerthNara <sankeerthnara@gmail.com> * perf: frontend request cache, poll pause, and code-split shell (#1052) Host/status caching, shell code-split, SSH pool waits, host-metrics concurrency, background-tab idle, per-host status subscriptions, homepage poll quieting, and virtualized host sidebar + file manager lists. * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * feat: save quick connect sessions as hosts (#1055) * fix: restore sudo password autofill settings (#1056) * fix: preserve file editor position on save (#1057) * fix: sync cloud preference storage mode (#1058) * fix: render RDP sessions at native pixel density (#1059) * fix: restore database import in embedded desktop mode (#1060) * Update Auto-complete.tsx (#1061) * chore: fix release workflow to merge docs branch * fix: svg donation generator push fail * fix: svg donation generator push fail * Update termix.rb * fix: svg donation generator push fail * chore: move donation badge to badges branch to avoid ruleset conflicts * chore: remove unneeded token from donation badge workflow * chore: debug donation badge commit step * fix: escape < character in donation SVG * fix: point donation badge to badges branch * chore: remove unused donation badge svg from main * Add Rack Genius logo to README Added Rack Genius logo to the README. * chore: improve donation goal svg generator to include stablecoins * chore: donation goal generator syntax error * chore: donation goal generator incorrect docs url usage * chore: donation bar reporting wrong result * feat: add Open File Manager to tab right-click menu (#1046) * Revert "feat: add Open File Manager to tab right-click menu (#1046)" (#1050) This reverts commit0712fdd731. * Remove donation badge from README Removed donation badge from README. * Delete .github/workflows/donation-goal.yml * Update Auto-complete.tsx --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> * feat(auth): opt-in OIDC DEK unlock for API-key requests (ALLOW_APIKEY_DATA_UNLOCK) (#1064) * chore: fix release workflow to merge docs branch * fix: svg donation generator push fail * fix: svg donation generator push fail * Update termix.rb * fix: svg donation generator push fail * chore: move donation badge to badges branch to avoid ruleset conflicts * chore: remove unneeded token from donation badge workflow * chore: debug donation badge commit step * fix: escape < character in donation SVG * fix: point donation badge to badges branch * chore: remove unused donation badge svg from main * Add Rack Genius logo to README Added Rack Genius logo to the README. * chore: improve donation goal svg generator to include stablecoins * chore: donation goal generator syntax error * chore: donation goal generator incorrect docs url usage * chore: donation bar reporting wrong result * feat: add Open File Manager to tab right-click menu (#1046) * Revert "feat: add Open File Manager to tab right-click menu (#1046)" (#1050) This reverts commit0712fdd731. * Remove donation badge from README Removed donation badge from README. * Delete .github/workflows/donation-goal.yml * feat(auth): opt-in OIDC DEK unlock for API-key requests API keys authenticate but cannot touch the encrypted credential/host store ('User data not unlocked') unless the user has a live interactive session, making them unusable for headless automation. For OIDC users the DEK is server-derivable (deriveOIDCSystemKey), so handleApiKeyAuth can unlock it without a password. Gated behind ALLOW_APIKEY_DATA_UNLOCK (default off) because enabling it widens the blast radius of a leaked API key. OIDC-only; password users are untouched. Refs #1063 --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Sankeerth Nara <sankeerthnara@gmail.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> * chore: package lock sync * Add Proxmox guest auto sync (#1053) * draft: database layer refactor (#1054) * feat(sshid) - sshid.io equivalent for termix (#919) * feat(ssh-id): database schema, migrations and field encryption Adds ssh_identities, ssh_identity_keys and ssh_identity_ca tables (public keys stored plaintext for the unauthenticated resolver; CA private key registered for per-user field encryption), with UNIQUE(user_id), an index on ssh_identity_keys(identity_id), and idempotent CREATE TABLE migrations. * feat(ssh-id): backend API — resolver, key management, CA and certificates Mounts /sshid (nginx route added). Public text/plain authorized_keys resolver (+ exact /:algo filter, HTML viewer) and CA public-key endpoint; no-store + noindex headers on every resolver response including early 404s. Authenticated management: claim/rename/delete handle, add/import/generate/enable/delete keys, and a per-user CA (create/rotate/delete) with pure-Node OpenSSH certificate issuance. Audit logging on all mutations; UNIQUE races map to a precise 409. Unit tests for key parsing and certificate signing (ssh-keygen-validated). * feat(ssh-id): frontend panel, API client and i18n SSH ID panel wired into the app rail and AppShell: claim handle, resolver URL + curl one-liner, key list, generate, paste/import, CA enable/rotate/remove with server trust command, and per-key certificate issuance. API client re-exported through main-axios.ts; all strings i18n'd. * style(ssh-id): align panel and resolver page with Termix theme - Rebuild the SSH ID sidebar panel with the theme's square components (SectionCard / SettingRow / FakeSwitch) instead of rounded ad-hoc cards; use accent-brand and destructive tokens rather than raw red/green. - Fix panel scrolling: move overflow to a block scroll container so the cards keep their natural height instead of being clipped. - Restyle the public resolver HTML page (/sshid/u/:handle) to the Termix dark theme: square corners, #18181b/#303032 palette, #f59145 accent, uppercase section labels. - Tidy copy: 'Save To Credentials' label, drop the redundant generate intro, and correct the generate tooltip (the key is stored when saving to vault). * feat: rename to Termix ID, improve UI, backend inconsistencies, and general bug fixes --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> * ci(deps): bump actions/checkout from 6 to 7 in the github-actions group (#922) Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout). Updates `actions/checkout` from 6 to 7 - [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/v6...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump the dev-patch-updates group with 11 updates (#923) Bumps the dev-patch-updates group with 11 updates: | Package | From | To | | --- | --- | --- | | [@codemirror/search](https://github.com/codemirror/search) | `6.7.0` | `6.7.1` | | [@codemirror/view](https://github.com/codemirror/view) | `6.43.0` | `6.43.1` | | [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.3.0` | `4.3.1` | | [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.8` | `4.1.9` | | [@vitest/ui](https://github.com/vitest-dev/vitest/tree/HEAD/packages/ui) | `4.1.8` | `4.1.9` | | [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.2` | `0.5.3` | | [lint-staged](https://github.com/lint-staged/lint-staged) | `17.0.7` | `17.0.8` | | [prettier](https://github.com/prettier/prettier) | `3.8.3` | `3.8.4` | | [sharp](https://github.com/lovell/sharp) | `0.35.1` | `0.35.2` | | [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.0` | `4.3.1` | | [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.8` | `4.1.9` | Updates `@codemirror/search` from 6.7.0 to 6.7.1 - [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md) - [Commits](https://github.com/codemirror/search/commits) Updates `@codemirror/view` from 6.43.0 to 6.43.1 - [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md) - [Commits](https://github.com/codemirror/view/commits) Updates `@tailwindcss/vite` from 4.3.0 to 4.3.1 - [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.3.1/packages/@tailwindcss-vite) Updates `@vitest/coverage-v8` from 4.1.8 to 4.1.9 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/coverage-v8) Updates `@vitest/ui` from 4.1.8 to 4.1.9 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/ui) Updates `eslint-plugin-react-refresh` from 0.5.2 to 0.5.3 - [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases) - [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md) - [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.2...v0.5.3) Updates `lint-staged` from 17.0.7 to 17.0.8 - [Release notes](https://github.com/lint-staged/lint-staged/releases) - [Changelog](https://github.com/lint-staged/lint-staged/blob/main/CHANGELOG.md) - [Commits](https://github.com/lint-staged/lint-staged/compare/v17.0.7...v17.0.8) Updates `prettier` from 3.8.3 to 3.8.4 - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/prettier/compare/3.8.3...3.8.4) Updates `sharp` from 0.35.1 to 0.35.2 - [Release notes](https://github.com/lovell/sharp/releases) - [Commits](https://github.com/lovell/sharp/compare/v0.35.1...v0.35.2) Updates `tailwindcss` from 4.3.0 to 4.3.1 - [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.3.1/packages/tailwindcss) Updates `vitest` from 4.1.8 to 4.1.9 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/vitest) --- updated-dependencies: - dependency-name: "@codemirror/search" dependency-version: 6.7.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@codemirror/view" dependency-version: 6.43.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@tailwindcss/vite" dependency-version: 4.3.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@vitest/coverage-v8" dependency-version: 4.1.9 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@vitest/ui" dependency-version: 4.1.9 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: eslint-plugin-react-refresh dependency-version: 0.5.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: lint-staged dependency-version: 17.0.8 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: prettier dependency-version: 3.8.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: sharp dependency-version: 0.35.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: tailwindcss dependency-version: 4.3.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: vitest dependency-version: 4.1.9 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump nanoid in the prod-patch-updates group (#925) Bumps the prod-patch-updates group with 1 update: [nanoid](https://github.com/ai/nanoid). Updates `nanoid` from 5.1.11 to 5.1.15 - [Release notes](https://github.com/ai/nanoid/releases) - [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md) - [Commits](https://github.com/ai/nanoid/compare/5.1.11...5.1.15) --- updated-dependencies: - dependency-name: nanoid dependency-version: 5.1.15 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the major-updates group with 5 updates (#926) Bumps the major-updates group with 5 updates: | Package | From | To | | --- | --- | --- | | [js-yaml](https://github.com/nodeca/js-yaml) | `4.2.0` | `5.0.0` | | [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) | `9.39.4` | `10.0.1` | | [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.9.2` | `26.0.0` | | [concurrently](https://github.com/open-cli-tools/concurrently) | `9.2.1` | `10.0.3` | | [eslint](https://github.com/eslint/eslint) | `9.39.4` | `10.5.0` | Updates `js-yaml` from 4.2.0 to 5.0.0 - [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/4.2.0...5.0.0) Updates `@eslint/js` from 9.39.4 to 10.0.1 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/commits/v10.0.1/packages/js) Updates `@types/node` from 25.9.2 to 26.0.0 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) Updates `concurrently` from 9.2.1 to 10.0.3 - [Release notes](https://github.com/open-cli-tools/concurrently/releases) - [Commits](https://github.com/open-cli-tools/concurrently/compare/v9.2.1...v10.0.3) Updates `eslint` from 9.39.4 to 10.5.0 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/compare/v9.39.4...v10.5.0) --- updated-dependencies: - dependency-name: js-yaml dependency-version: 5.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: major-updates - dependency-name: "@eslint/js" dependency-version: 10.0.1 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates - dependency-name: "@types/node" dependency-version: 26.0.0 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates - dependency-name: concurrently dependency-version: 10.0.3 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates - dependency-name: eslint dependency-version: 10.5.0 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * feat(ssh): add HashiCorp Vault SSH signer authentication * fix: small fixes to vault feature to align with Termix codebase * chore: add view docs links for vault/termix id * fix: file upload fails with 400 and missing schema migrations on upgrade (#929) Two bugs introduced in v2.4.1: 1. uploadFileStream uses fileManagerApi.post() which triggers axios's transformRequest to JSON-serialize the FormData because the instance default Content-Type is application/json. Change to postForm() which sets Content-Type: multipart/form-data so the browser XHR sends the correct multipart body with boundary. 2. Two schema items added to schema.ts were not included in migrateSchema() in db/index.ts, causing 500 errors on existing installations upgrading from v2.4.0: - user_preferences.status_color_scheme (no such column) - dashboard_service_links table (no such table) Fixes #928 Co-authored-by: sash <sash@fominykh.io> * fix: support PuTTY PPK ssh keys (#930) * fix: chunk large file manager uploads (#932) * fix: route dashboard hosts by protocol (#934) * fix: resolve tunnel endpoints reliably (#935) * Fix Electron OIDC browser auth failures (#936) * Allow RDP connections without stored credentials (#937) * Sync role credential shares for OIDC users (#938) * Fix terminal link dialog layering (#940) * Confirm large files before opening editor (#942) * Confirm closing active host connections (#943) * Preserve file path case in file manager UI (#941) * fix: preserve unicode guacamole tokens (#933) * Persist VNC authentication settings (#944) * Fix Guacamole websocket base path (#946) * Promote file manager terminals to tabs (#939) * Guard Guacamole disconnect during startup (#945) * chore: increment ver * feat: bitwarden ssh agent integration * feat: serial connections support * fix: various small bug fixes * feat: open all sessions in a folder and terminal custom theme color support * feat: cross host file manager clipboard and several small bug fixes * feat: tailscale/wireguard support and added a new status state for when backend is checking status * feat: grafana like server stats history, new alert system, ntfy/webhook support * feat: new grid and widget based homepage function * feat: new donate button in dashboard * fix: alert ui incorrectly using termix css and fixed issue with alert system not loading * chore: start database layer refactor * docs: plan database layer refactor * docs: audit database layer refactor phase zero * chore: add database runtime adapter skeleton * chore: add settings repository skeleton * chore: add user session repository skeleton * chore: add host credential repository skeleton * chore: add field encryption boundary * chore: migrate settings route slice * chore: migrate user settings routes * chore: migrate host metrics settings routes * chore: migrate acme settings route * chore: migrate terminal settings route * chore: migrate tailscale settings read * chore: migrate guacamole settings reads * chore: migrate session timeout settings reads * chore: migrate auth route settings reads * chore: migrate host metrics settings reads * chore: migrate startup settings reads * chore: migrate user settings cleanup * chore: migrate password reset settings * chore: migrate oidc legacy settings read * chore: migrate user route settings slice * chore: migrate oidc state settings * chore: migrate user login settings reads * chore: migrate user crypto settings * chore: consolidate startup settings defaults * chore: consolidate database settings import export * chore: migrate core session auth paths * chore: migrate remaining session auth paths * chore: migrate admin user routes * chore: migrate user route admin checks * chore: migrate user lifecycle routes * chore: migrate auth user lookups * chore: migrate oidc user routes * chore: migrate api key repository paths * docs: add database gray rollout guide * chore: migrate trusted device paths * chore: migrate user session route user lookups * chore: add database repository rollout guard * chore: expose repository rollout status * chore: warn on repository rollout misconfiguration * chore: migrate remaining user lookup helpers * chore: migrate ssh user lookups * chore: migrate user settings admin lookups * chore: migrate acme ssl user lookups * chore: migrate audit log admin checks * chore: migrate oidc account user updates * chore: migrate password reset user updates * chore: migrate user deletion core records * chore: migrate snippet audit user lookups * chore: migrate ldap user sync paths * chore: migrate totp user updates * chore: migrate rbac user checks * chore: migrate rbac role paths * chore: migrate permission role lookups * chore: migrate rbac access list reads * chore: migrate shared rbac reads * chore: migrate rbac access writes * chore: migrate permission host access * chore: migrate role host access lookup * chore: migrate snippet access lookup * chore: migrate shared credential access lookups * chore: migrate host access cleanup writes * chore: migrate host list access checks * chore: migrate host access cleanup routes * chore: migrate shared credential role lookups * chore: migrate user role cleanup * chore: migrate admin role sync * chore: migrate ldap role sync * chore: migrate user role assignment * chore: migrate sso provider access * chore: migrate audit log access * chore: migrate user preference access * chore: migrate open tab access * chore: migrate dismissed alert access * chore: migrate homepage layout access * chore: migrate network topology access * chore: migrate dashboard service link access * chore: migrate command history access * chore: migrate recent activity cleanup * chore: migrate ssh credential usage access * chore: migrate transfer recent access * chore: migrate file manager bookmark access * chore: migrate c2s tunnel preset access * chore: migrate homepage item access * chore: migrate session recording access * chore: migrate tmux session tag access * chore: migrate opkssh token access * chore: migrate vault token access * chore: migrate vault profile access * chore: migrate host metrics preference access * chore: migrate host health access * chore: migrate host metrics history access * chore: migrate alert persistence access * chore: route alert host lookup through repository * chore: migrate user data export reads * chore: route host metrics stats sync through repository * chore: migrate host folder persistence * chore: migrate host resolution reads * chore: route jump host resolution reads * chore: route docker console jump host reads * chore: route docker ssh resolution reads * chore: route proxmox discovery resolution reads * chore: route file manager activity host reads * chore: route host metrics resolution reads * chore: route ssh auth credential reads * chore: route tunnel endpoint credential reads * chore: route credential deployment resolution reads * chore: route command history host flag reads * chore: route snippet execution resolution reads * chore: route terminal host resolution reads * chore: route vault oidc host resolution reads * chore: route wake on lan host reads * chore: route internal host list reads * chore: route host key verification persistence * chore: route credential read paths * chore: route credential host usage reads * chore: route credential folder rename * chore: route host owner access checks * chore: route shared credential source reads * chore: route user host credential cleanup * chore: route credential delete reads * chore: route credential update reads * chore: route host credential reads * chore: route host read paths * chore: route host projection reads * chore: route host list reads * chore: route snippet read paths * chore: route snippet folder writes * chore: route snippet crud paths * chore: route snippet bulk import * chore: route rbac ownership reads * chore: route user count reads * chore: route cleanup snippets folders * chore: route shared credential persistence * chore: route dashboard activity * chore: route guacamole host reads * chore: route host bulk lookups * chore: remove unlock-only simple db ops * chore: route host autostart persistence * chore: route ldap provisioning through users * chore: route credential encrypted writes * chore: route host encrypted writes * chore: route bulk host encrypted writes * chore: route termix id credentials * chore: route termix id ca persistence * chore: route termix identity persistence * chore: route credential system migration * chore: isolate user encryption migration storage * chore: remove legacy simple db ops * chore: isolate legacy sqlite migration copy * chore: route database settings import export * chore: route database host credential export * chore: route database host credential import * chore: route database file-manager import export * chore: route database alert usage import export * chore: route database user checks * chore: isolate auth lazy migration storage * chore: route explicit database saves * chore: initialize database save boundary * chore: route migration snapshot saves * chore: isolate sqlite import constraints * chore: route import sqlite boundary * chore: route user encryption migration store * chore: centralize current repository runtime * chore: route more current repositories * chore: route activity repository runtimes * chore: route token repository runtimes * chore: route health repository runtimes * chore: route identity repository runtimes * chore: route rbac repository runtime * chore: centralize current sqlite runtime access * chore: route user deletion key cleanup * chore: route user deletion vault cleanup * chore: route user deletion homepage cleanup * chore: route user deletion health cleanup * chore: route user deletion alert cleanup * chore: route user deletion identity cleanup * chore: add database layer preupgrade backup * Fix database repository type errors * fix: complete post-merge compile fixes for database refactor Restore missing DatabaseSaveTrigger/getDb imports, session log format fallback, OIDC provider resolution, guacamole recording insert, and passwordFallbackOnly typing after merging current dev. --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: DivByZero <mr.oplus@yahoo.fr> Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: devdanetra <46488477+devdanetra@users.noreply.github.com> Co-authored-by: Aleksandr Fominykh <neoformalex@users.noreply.github.com> Co-authored-by: sash <sash@fominykh.io> * refactor(db): collapse repository rollout scaffolding into single factory Repositories are now the only data path. Replaces the 41 current-*-repository wrapper files, the DATABASE_LAYER_REPOSITORY_ROLLOUT flag/alias map and the unused database/runtime adapter with repositories/factory.ts, a plain DatabaseContext type and an in-memory TestSqliteDatabase test harness. * refactor(db): route remaining raw DB access through repositories proxmox, session-log, oidc-utils, webauthn and guacamole recording now use repositories (new WebauthnCredentialRepository; SsoProviderRepository listEnabled; HostRepository findDecryptedByIdAs/listProxmoxEnabled). Remaining raw access: db boot code, simple-db-ops and docker.ts, which are removed/restructured in later phases. * feat(crypto): add UserKeyManager with system-wrapped per-user DEKs New utils/user-keys.ts: one random 32-byte DEK per user, wrapped AES-256-GCM under an HKDF key derived from the system ENCRYPTION_KEY (per-user info string + AAD binding, versioned v3 wrap format stored in settings). Synchronous unwrap-on-demand with a 15-minute cache so the existing DataCrypto facade keeps its sync call sites. Not wired up yet. * feat(crypto): boot-time DEK migration to system-wrapped v3 format utils/crypto-migration/dek-migration.ts carries the legacy unwrap paths (PBKDF2 password KEK, OIDC/WebAuthn system keys, hardcoded-default fallback) and migrates every server-unwrappable DEK to the v3 wrap at startup. Password-wrapped DEKs migrate at next login or from a live session via adoptRecoveredDEK. Legacy rows are kept for now; cleanup flips on once the new path is authoritative. * refactor(crypto): make system-wrapped DEKs the authoritative key path DataCrypto and AuthManager now read keys through UserKeyManager: DEKs are always unwrappable server-side, so the in-memory unlock session, DEK-in-JWT wrapping, session-expiry data locks and ALLOW_APIKEY_DATA_UNLOCK are gone. utils/user-crypto.ts is deleted; boot migration now cleans legacy wraps. A one-release shim adopts DEKs from legacy dataKeyWrap tokens so active password users migrate without re-login. Password login migrates legacy password-wrapped DEKs via migratePasswordUserAtLogin. * refactor(crypto): remove pending share queue and credential sharing key With server-unwrappable DEKs both sides of a share are always available, so the needsReEncryption queue, CREDENTIAL_SHARING_KEY and the system_* shadow columns on ssh_credentials are gone. A one-time boot cleanup re-creates legacy pending share copies where possible (dropping unresolvable ones with a warning) and drops the legacy columns. * feat(auth): non-destructive password resets and admin reset endpoint Password resets no longer destroy user data: the DEK is system-wrapped, so forgot-password and admin resets are just a hash update plus session revoke. The wipe branch survives only for accounts that never logged in since the encryption upgrade and now requires explicit confirmDataWipe (surfaced as a 409 DATA_WIPE_REQUIRED; the reset UI asks for confirmation). Adds POST /users/admin/reset-password and removes the dead re-encryption paths. * refactor(ssh): consolidate four jump-host chain copies into one module terminal, host-metrics and docker now use ssh/jump-host-chain.ts (already shared by file-manager, tmux-monitor and docker-console); docker's inline copy also drops its raw SimpleDBOps host/credential lookups in favor of repositories. * refactor(ssh): single shared createConnectionLog helper file-manager-log.ts becomes ssh/connection-log.ts; the copies in docker.ts and host-metrics-helpers.ts are gone. * refactor(ssh): split docker module into layered directory ssh/docker/{index,routes,session-manager,container-routes,console}.ts: server boot and wiring in index, HTTP handlers in routes, SSH session registry and command execution in session-manager. Code motion only; port 30007/30009 and endpoints unchanged. Swagger now scans ssh subdirectories. * refactor(ssh): split tunnel module into layered directory ssh/tunnel/{index,routes,manager}.ts: server boot in index, HTTP handlers in routes, tunnel state and engine (connect/retry/autostart) in manager. Code motion only; port 30003 and endpoints unchanged. * refactor(backend): reorganize top-level layout - ssh/ renamed to hosts/ (it covers SSH, RDP, VNC, Telnet, Docker, metrics) - serial/serial.ts and guacamole/ moved inside hosts/ - dashboard.ts and homepage.ts moved to services/ - swagger.ts moved to utils/ with adjusted scan globs Import paths and the generate:openapi script updated; ports and endpoints unchanged. * refactor(tests): move backend tests into src/backend/tests mirror tree Backend *.test.ts files (and the test-support harness) no longer sit next to source files; they live under src/backend/tests/ mirroring the source layout. Imports rewritten accordingly; CLAUDE.md convention updated. * refactor(hosts): group host modules into per-feature directories file-manager/, metrics/ (incl. widgets, managers, alert-engine), terminal/, tmux/ and tunnel/ each own their files; docker/ gains container-runtime. Genuinely shared helpers (jump-host chain, host resolver, connection pool, opkssh, vault, serial) stay at hosts/ root. Pure file moves with import path updates; mirrored test paths follow. * refactor(backend): final cleanup pass - re-register WebAuthn passkey routes (registration was dropped in the #1054 merge, breaking passkey login) and document all six endpoints - delete utils/simple-db-ops.ts (last caller migrated to DataCrypto) - starter: use the typed serverReady export, collapse the four-way version lookup to env then package.json candidates - add OpenAPI JSDoc to c2s-tunnel-presets endpoints - strip block-divider comment banners * feat: remove legacy "data_unlocked" field * feat: refactor rbac/sharing to support new permissions and auth types * feat: refactor rbac/sharing to support new permissions and auth types * feat: add "id" to user profile hide list * chore: root cleanup * feat: add more donation references and a 30-day donation reminder * chore: update readme * feat: automate beta tests * feat: add links to milestones * fix: hoist github/google SSO defaults to module scope (#1065) * fix(ssh-tools): allow clipboard paste in key recording field (#1066) The broadcast key-recording input was marked readOnly, which makes browsers block paste entirely (no context-menu Paste, Ctrl+V does nothing). handleKeyDown also called preventDefault() unconditionally, swallowing the Ctrl+V shortcut before a paste event could even fire. Let Ctrl/Cmd+V pass through in handleKeyDown, drop readOnly, and add an onPaste handler that reads the clipboard text and broadcasts it to the selected terminals like any other captured keystroke. Signed-off-by: emreumar <emreumar@users.noreply.github.com> Co-authored-by: emreumar <emreumar@users.noreply.github.com> * chore: write release notes * chore: update release notes * chore: update readmes * chore: add crypto only reminder in en.json * fix: macOS and cask errors on release workflow * chore: sync Crowdin translations for 2.5.1 --------- Signed-off-by: dependabot[bot] <support@github.com> Signed-off-by: emreumar <emreumar@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: Russell Poovey <09.our_seekers@icloud.com> Co-authored-by: russell <git@0896c69e.com> Co-authored-by: Subedi Bibek <77529535+questbibek@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Alexander Elsner <101340634+Bensonheimer992@users.noreply.github.com> Co-authored-by: SankeerthNara <sankeerthnara@gmail.com> Co-authored-by: Stephan Groth <96803994+Kalvalax@users.noreply.github.com> Co-authored-by: DivByZero <mr.oplus@yahoo.fr> Co-authored-by: devdanetra <46488477+devdanetra@users.noreply.github.com> Co-authored-by: Aleksandr Fominykh <neoformalex@users.noreply.github.com> Co-authored-by: sash <sash@fominykh.io> Co-authored-by: lhojun <ldgs3324@gmail.com> Co-authored-by: Yunus Emre Umar <77045015+emre155@users.noreply.github.com> Co-authored-by: emreumar <emreumar@users.noreply.github.com>
This commit is contained in:
co-authored by
emreumar
dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ZacharyZcR
Russell Poovey
russell
Subedi Bibek
Claude Fable 5
Alexander Elsner
SankeerthNara
Stephan Groth
DivByZero
devdanetra
Aleksandr Fominykh
sash
lhojun
Yunus Emre Umar
parent
fba645e92e
commit
ddbdd5c437
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mapAlertFiring } from "../../api/alerts-api";
|
||||
|
||||
describe("mapAlertFiring", () => {
|
||||
it("normalizes sqlite snake_case rows for the alerts UI", () => {
|
||||
expect(
|
||||
mapAlertFiring({
|
||||
id: 9,
|
||||
user_id: "u1",
|
||||
rule_id: 4,
|
||||
host_id: 12,
|
||||
host_name: "db-01",
|
||||
fired_at: "2026-06-30 12:00:00",
|
||||
resolved_at: null,
|
||||
value: 91,
|
||||
message: "CPU threshold exceeded",
|
||||
severity: "critical",
|
||||
acknowledged: 0,
|
||||
rule_name: "CPU",
|
||||
}),
|
||||
).toEqual({
|
||||
id: 9,
|
||||
userId: "u1",
|
||||
ruleId: 4,
|
||||
hostId: 12,
|
||||
hostName: "db-01",
|
||||
firedAt: "2026-06-30 12:00:00",
|
||||
resolvedAt: null,
|
||||
value: 91,
|
||||
message: "CPU threshold exceeded",
|
||||
severity: "critical",
|
||||
acknowledged: false,
|
||||
ruleName: "CPU",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses safe defaults for malformed rows", () => {
|
||||
const firing = mapAlertFiring({ acknowledged: 1, severity: "bad" });
|
||||
|
||||
expect(firing.id).toBe(0);
|
||||
expect(firing.hostName).toBe("");
|
||||
expect(firing.severity).toBe("warning");
|
||||
expect(firing.acknowledged).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mapAuditLog } from "../../api/audit-log-api";
|
||||
|
||||
describe("mapAuditLog", () => {
|
||||
it("normalizes sqlite snake_case rows for the audit log UI", () => {
|
||||
expect(
|
||||
mapAuditLog({
|
||||
id: 3,
|
||||
user_id: "u1",
|
||||
username: "admin",
|
||||
action: "host_create",
|
||||
resource_type: "host",
|
||||
resource_id: "42",
|
||||
resource_name: "prod",
|
||||
details: "created",
|
||||
ip_address: "127.0.0.1",
|
||||
user_agent: "browser",
|
||||
success: 1,
|
||||
error_message: null,
|
||||
timestamp: "2026-06-30 12:00:00",
|
||||
}),
|
||||
).toEqual({
|
||||
id: 3,
|
||||
userId: "u1",
|
||||
username: "admin",
|
||||
action: "host_create",
|
||||
resourceType: "host",
|
||||
resourceId: "42",
|
||||
resourceName: "prod",
|
||||
details: "created",
|
||||
ipAddress: "127.0.0.1",
|
||||
userAgent: "browser",
|
||||
success: true,
|
||||
errorMessage: null,
|
||||
timestamp: "2026-06-30 12:00:00",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses render-safe defaults for malformed rows", () => {
|
||||
const log = mapAuditLog({ success: 0 });
|
||||
|
||||
expect(log.id).toBe(0);
|
||||
expect(log.action).toBe("unknown");
|
||||
expect(log.resourceType).toBe("unknown");
|
||||
expect(log.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
clampColumns,
|
||||
nextColSpan,
|
||||
sortSlots,
|
||||
reorderSlots,
|
||||
addSlot,
|
||||
removeSlot,
|
||||
setColSpan,
|
||||
setHeight,
|
||||
heightToRowSpan,
|
||||
MIN_TILE_HEIGHT,
|
||||
} from "../../../components/card-grid/layout-utils";
|
||||
import type { GridSlot } from "../../../components/card-grid/types";
|
||||
|
||||
function slot(id: string, order: number, colSpan: 1 | 2 | 3 = 1): GridSlot {
|
||||
return { id, order, colSpan, height: null };
|
||||
}
|
||||
|
||||
describe("clampColumns", () => {
|
||||
it("clamps into 1..4 and rounds", () => {
|
||||
expect(clampColumns(0)).toBe(1);
|
||||
expect(clampColumns(1)).toBe(1);
|
||||
expect(clampColumns(4)).toBe(4);
|
||||
expect(clampColumns(9)).toBe(4);
|
||||
expect(clampColumns(2.6)).toBe(3);
|
||||
expect(clampColumns(NaN)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextColSpan", () => {
|
||||
it("cycles 1->2->3->1 when 3+ columns", () => {
|
||||
expect(nextColSpan(1, 3)).toBe(2);
|
||||
expect(nextColSpan(2, 3)).toBe(3);
|
||||
expect(nextColSpan(3, 3)).toBe(1);
|
||||
});
|
||||
it("caps at the column count", () => {
|
||||
expect(nextColSpan(1, 2)).toBe(2);
|
||||
expect(nextColSpan(2, 2)).toBe(1);
|
||||
expect(nextColSpan(1, 1)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortSlots", () => {
|
||||
it("returns a stable copy ordered by order", () => {
|
||||
const input = [slot("b", 2), slot("a", 1), slot("c", 3)];
|
||||
const out = sortSlots(input);
|
||||
expect(out.map((s) => s.id)).toEqual(["a", "b", "c"]);
|
||||
expect(out).not.toBe(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reorderSlots", () => {
|
||||
const base = [slot("a", 0), slot("b", 1), slot("c", 2)];
|
||||
|
||||
it("moves a slot to the front and renumbers densely", () => {
|
||||
const out = reorderSlots(base, "c", 0);
|
||||
expect(out.map((s) => s.id)).toEqual(["c", "a", "b"]);
|
||||
expect(out.map((s) => s.order)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it("moves a slot to the end", () => {
|
||||
const out = reorderSlots(base, "a", 99);
|
||||
expect(out.map((s) => s.id)).toEqual(["b", "c", "a"]);
|
||||
expect(out.map((s) => s.order)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it("is a no-op-ish renumber for an unknown id", () => {
|
||||
const out = reorderSlots(base, "zzz", 0);
|
||||
expect(out.map((s) => s.id)).toEqual(["a", "b", "c"]);
|
||||
expect(out.map((s) => s.order)).toEqual([0, 1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addSlot", () => {
|
||||
it("appends with clamped colSpan and next order", () => {
|
||||
const out = addSlot(
|
||||
[slot("a", 0)],
|
||||
{ id: "b", label: "B", defaultColSpan: 3 },
|
||||
2,
|
||||
);
|
||||
expect(out).toHaveLength(2);
|
||||
const added = out.find((s) => s.id === "b")!;
|
||||
expect(added.order).toBe(1);
|
||||
expect(added.colSpan).toBe(2); // clamped to columns
|
||||
});
|
||||
it("does not duplicate an existing id", () => {
|
||||
const input = [slot("a", 0)];
|
||||
expect(addSlot(input, { id: "a", label: "A" }, 3)).toBe(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeSlot", () => {
|
||||
it("removes and renumbers", () => {
|
||||
const out = removeSlot([slot("a", 0), slot("b", 1), slot("c", 2)], "b");
|
||||
expect(out.map((s) => s.id)).toEqual(["a", "c"]);
|
||||
expect(out.map((s) => s.order)).toEqual([0, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setColSpan / setHeight", () => {
|
||||
it("sets colSpan for the matching id only", () => {
|
||||
const out = setColSpan([slot("a", 0, 1), slot("b", 1, 1)], "a", 2);
|
||||
expect(out.find((s) => s.id === "a")!.colSpan).toBe(2);
|
||||
expect(out.find((s) => s.id === "b")!.colSpan).toBe(1);
|
||||
});
|
||||
it("enforces a minimum tile height", () => {
|
||||
const out = setHeight([slot("a", 0)], "a", 10);
|
||||
expect(out[0].height).toBe(MIN_TILE_HEIGHT);
|
||||
const out2 = setHeight([slot("a", 0)], "a", 300);
|
||||
expect(out2[0].height).toBe(300);
|
||||
});
|
||||
});
|
||||
|
||||
describe("heightToRowSpan", () => {
|
||||
const unit = 8;
|
||||
const gap = 12;
|
||||
it("returns at least one row for tiny tiles", () => {
|
||||
expect(heightToRowSpan(0, unit, gap)).toBe(1);
|
||||
expect(heightToRowSpan(5, unit, gap)).toBe(1);
|
||||
});
|
||||
it("grows the row span with height (taller -> more rows)", () => {
|
||||
const short = heightToRowSpan(200, unit, gap);
|
||||
const tall = heightToRowSpan(400, unit, gap);
|
||||
expect(tall).toBeGreaterThan(short);
|
||||
});
|
||||
it("rounds up so content is never clipped", () => {
|
||||
// 200px content: ceil((200 + 12) / (8 + 12)) = ceil(10.6) = 11
|
||||
expect(heightToRowSpan(200, unit, gap)).toBe(11);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
sparklineGeometry,
|
||||
gaugeArc,
|
||||
SPARKLINE_VIEW_W,
|
||||
} from "../../../components/charts/geometry";
|
||||
|
||||
describe("sparklineGeometry", () => {
|
||||
it("returns no data for fewer than 2 points", () => {
|
||||
expect(sparklineGeometry([], 50).hasData).toBe(false);
|
||||
expect(sparklineGeometry([5], 50).hasData).toBe(false);
|
||||
});
|
||||
|
||||
it("maps endpoints to the full width", () => {
|
||||
const g = sparklineGeometry([0, 100], 100, [0, 100]);
|
||||
expect(g.coords[0][0]).toBe(0);
|
||||
expect(g.coords[1][0]).toBe(SPARKLINE_VIEW_W);
|
||||
});
|
||||
|
||||
it("inverts the y-axis (higher value = smaller y)", () => {
|
||||
const g = sparklineGeometry([0, 100], 100, [0, 100]);
|
||||
expect(g.coords[0][1]).toBe(100); // value 0 -> bottom
|
||||
expect(g.coords[1][1]).toBe(0); // value 100 -> top
|
||||
});
|
||||
|
||||
it("respects a fixed domain (no rescaling)", () => {
|
||||
// With domain [0,100], a mid value of 50 sits at half height regardless
|
||||
// of the other points.
|
||||
const g = sparklineGeometry([50, 50, 50], 100, [0, 100]);
|
||||
for (const [, y] of g.coords) expect(y).toBe(50);
|
||||
});
|
||||
|
||||
it("clamps out-of-domain values", () => {
|
||||
const g = sparklineGeometry([-20, 200], 100, [0, 100]);
|
||||
expect(g.coords[0][1]).toBe(100); // clamped to lo
|
||||
expect(g.coords[1][1]).toBe(0); // clamped to hi
|
||||
});
|
||||
|
||||
it("treats null/NaN as 0", () => {
|
||||
const g = sparklineGeometry([null, undefined, NaN], 100, [0, 100]);
|
||||
expect(g.hasData).toBe(true);
|
||||
for (const [, y] of g.coords) expect(y).toBe(100);
|
||||
});
|
||||
|
||||
it("builds line and area paths", () => {
|
||||
const g = sparklineGeometry([0, 100], 100, [0, 100]);
|
||||
expect(g.linePath.startsWith("M ")).toBe(true);
|
||||
expect(g.areaPath.startsWith("M 0,100")).toBe(true);
|
||||
expect(g.areaPath.endsWith("Z")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gaugeArc", () => {
|
||||
it("zero value yields zero value-arc", () => {
|
||||
const a = gaugeArc(0, 50);
|
||||
expect(a.valueLen).toBe(0);
|
||||
expect(a.trackLen).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("null is treated as 0", () => {
|
||||
expect(gaugeArc(null, 50).valueLen).toBe(0);
|
||||
});
|
||||
|
||||
it("100% fills the whole track", () => {
|
||||
const a = gaugeArc(100, 50);
|
||||
expect(a.valueLen).toBeCloseTo(a.trackLen, 6);
|
||||
});
|
||||
|
||||
it("track is the swept fraction of the circumference", () => {
|
||||
const a = gaugeArc(50, 50, 270);
|
||||
expect(a.trackLen).toBeCloseTo(a.circumference * 0.75, 6);
|
||||
expect(a.valueLen).toBeCloseTo(a.trackLen * 0.5, 6);
|
||||
});
|
||||
|
||||
it("clamps values above 100", () => {
|
||||
const a = gaugeArc(150, 50);
|
||||
expect(a.valueLen).toBeCloseTo(a.trackLen, 6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveProxmoxImportAuth } from "../../../components/proxmox/proxmox-import-auth";
|
||||
|
||||
describe("resolveProxmoxImportAuth", () => {
|
||||
it("uses credential auth when a credential is available", () => {
|
||||
expect(resolveProxmoxImportAuth(undefined, 42)).toEqual({
|
||||
authType: "credential",
|
||||
credentialId: 42,
|
||||
overrideCredentialUsername: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not import password auth without a password secret", () => {
|
||||
expect(resolveProxmoxImportAuth("password", null)).toEqual({
|
||||
authType: "none",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not import key auth without a private key secret", () => {
|
||||
expect(resolveProxmoxImportAuth("key", undefined)).toEqual({
|
||||
authType: "none",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps secretless auth types", () => {
|
||||
expect(resolveProxmoxImportAuth("opkssh", null)).toEqual({
|
||||
authType: "opkssh",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { formatFileSize } from "../../../features/file-manager/file-manager-utils.js";
|
||||
|
||||
describe("formatFileSize", () => {
|
||||
it("returns a dash for undefined or null", () => {
|
||||
expect(formatFileSize(undefined)).toBe("-");
|
||||
expect(formatFileSize(null as unknown as number)).toBe("-");
|
||||
});
|
||||
|
||||
it("returns 0 B for zero", () => {
|
||||
expect(formatFileSize(0)).toBe("0 B");
|
||||
});
|
||||
|
||||
it("formats bytes without decimals", () => {
|
||||
expect(formatFileSize(512)).toBe("512 B");
|
||||
expect(formatFileSize(1023)).toBe("1023 B");
|
||||
});
|
||||
|
||||
it("formats kilobytes with one decimal under 10", () => {
|
||||
expect(formatFileSize(1024)).toBe("1.0 KB");
|
||||
expect(formatFileSize(1536)).toBe("1.5 KB");
|
||||
});
|
||||
|
||||
it("rounds to whole numbers at or above 10 units", () => {
|
||||
expect(formatFileSize(10 * 1024)).toBe("10 KB");
|
||||
expect(formatFileSize(1024 * 1024)).toBe("1.0 MB");
|
||||
});
|
||||
|
||||
it("scales up to larger units", () => {
|
||||
expect(formatFileSize(1024 * 1024 * 1024)).toBe("1.0 GB");
|
||||
expect(formatFileSize(1024 * 1024 * 1024 * 1024)).toBe("1.0 TB");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { useFileSelection } from "../../../../features/file-manager/hooks/useFileSelection.js";
|
||||
|
||||
type FileItem = {
|
||||
name: string;
|
||||
type: "file" | "directory" | "link";
|
||||
path: string;
|
||||
};
|
||||
|
||||
const f = (name: string): FileItem => ({
|
||||
name,
|
||||
type: "file",
|
||||
path: `/dir/${name}`,
|
||||
});
|
||||
|
||||
describe("useFileSelection", () => {
|
||||
it("single-selects, replacing prior selection", () => {
|
||||
const { result } = renderHook(() => useFileSelection());
|
||||
act(() => result.current.selectFile(f("a")));
|
||||
act(() => result.current.selectFile(f("b")));
|
||||
expect(result.current.selectedFiles.map((x) => x.name)).toEqual(["b"]);
|
||||
expect(result.current.getSelectedCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("multi-selects and toggles off on repeat", () => {
|
||||
const { result } = renderHook(() => useFileSelection());
|
||||
act(() => result.current.selectFile(f("a"), true));
|
||||
act(() => result.current.selectFile(f("b"), true));
|
||||
expect(result.current.getSelectedCount()).toBe(2);
|
||||
act(() => result.current.selectFile(f("a"), true));
|
||||
expect(result.current.selectedFiles.map((x) => x.name)).toEqual(["b"]);
|
||||
});
|
||||
|
||||
it("reports isSelected by path", () => {
|
||||
const { result } = renderHook(() => useFileSelection());
|
||||
act(() => result.current.selectFile(f("a")));
|
||||
expect(result.current.isSelected(f("a"))).toBe(true);
|
||||
expect(result.current.isSelected(f("z"))).toBe(false);
|
||||
});
|
||||
|
||||
it("selects a contiguous range regardless of direction", () => {
|
||||
const { result } = renderHook(() => useFileSelection());
|
||||
const files = [f("a"), f("b"), f("c"), f("d")];
|
||||
act(() => result.current.selectRange(files, files[3], files[1]));
|
||||
expect(result.current.selectedFiles.map((x) => x.name)).toEqual([
|
||||
"b",
|
||||
"c",
|
||||
"d",
|
||||
]);
|
||||
});
|
||||
|
||||
it("selects all and clears", () => {
|
||||
const { result } = renderHook(() => useFileSelection());
|
||||
const files = [f("a"), f("b")];
|
||||
act(() => result.current.selectAll(files));
|
||||
expect(result.current.getSelectedCount()).toBe(2);
|
||||
act(() => result.current.clearSelection());
|
||||
expect(result.current.getSelectedCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("toggleSelection adds then removes", () => {
|
||||
const { result } = renderHook(() => useFileSelection());
|
||||
act(() => result.current.toggleSelection(f("a")));
|
||||
expect(result.current.isSelected(f("a"))).toBe(true);
|
||||
act(() => result.current.toggleSelection(f("a")));
|
||||
expect(result.current.isSelected(f("a"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildGuacamoleWebSocketBaseUrl } from "../../../features/guacamole/guacamole-websocket-url.js";
|
||||
|
||||
const httpsLocation = {
|
||||
protocol: "https:",
|
||||
host: "termix.example.com",
|
||||
} as Location;
|
||||
|
||||
describe("buildGuacamoleWebSocketBaseUrl", () => {
|
||||
it("uses the same origin guacamole websocket path in production web builds", () => {
|
||||
expect(
|
||||
buildGuacamoleWebSocketBaseUrl({
|
||||
isDev: false,
|
||||
isElectronApp: false,
|
||||
isEmbeddedApp: false,
|
||||
basePath: "",
|
||||
location: httpsLocation,
|
||||
}),
|
||||
).toBe("wss://termix.example.com/guacamole/websocket/");
|
||||
});
|
||||
|
||||
it("preserves the runtime base path for proxied web deployments", () => {
|
||||
expect(
|
||||
buildGuacamoleWebSocketBaseUrl({
|
||||
isDev: false,
|
||||
isElectronApp: false,
|
||||
isEmbeddedApp: false,
|
||||
basePath: "/termix",
|
||||
location: httpsLocation,
|
||||
}),
|
||||
).toBe("wss://termix.example.com/termix/guacamole/websocket/");
|
||||
});
|
||||
|
||||
it("keeps direct local websocket access for development and embedded electron", () => {
|
||||
expect(
|
||||
buildGuacamoleWebSocketBaseUrl({
|
||||
isDev: true,
|
||||
isElectronApp: false,
|
||||
isEmbeddedApp: false,
|
||||
basePath: "/termix",
|
||||
location: httpsLocation,
|
||||
}),
|
||||
).toBe("ws://localhost:30008");
|
||||
|
||||
expect(
|
||||
buildGuacamoleWebSocketBaseUrl({
|
||||
isDev: false,
|
||||
isElectronApp: true,
|
||||
isEmbeddedApp: true,
|
||||
basePath: "/termix",
|
||||
location: httpsLocation,
|
||||
}),
|
||||
).toBe("ws://127.0.0.1:30008");
|
||||
});
|
||||
|
||||
it("uses the configured remote server URL for electron remote mode", () => {
|
||||
expect(
|
||||
buildGuacamoleWebSocketBaseUrl({
|
||||
isDev: false,
|
||||
isElectronApp: true,
|
||||
isEmbeddedApp: false,
|
||||
configuredServerUrl: "https://termix.example.com/termix/",
|
||||
basePath: "",
|
||||
location: httpsLocation,
|
||||
}),
|
||||
).toBe("wss://termix.example.com/termix/guacamole/websocket/");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
isFirefoxBrowser,
|
||||
isPasteShortcut,
|
||||
pasteTextToRemote,
|
||||
type GuacamoleClipboardClient,
|
||||
} from "../../../features/guacamole/guacamole-clipboard.js";
|
||||
|
||||
describe("Guacamole Firefox clipboard fallback", () => {
|
||||
it("only enables the native paste path for Firefox", () => {
|
||||
expect(
|
||||
isFirefoxBrowser(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) Gecko/20100101 Firefox/140.0",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isFirefoxBrowser(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/140.0.0.0",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("recognizes Ctrl+V and Command+V without intercepting Alt+V", () => {
|
||||
expect(
|
||||
isPasteShortcut({
|
||||
key: "v",
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPasteShortcut({
|
||||
key: "V",
|
||||
ctrlKey: false,
|
||||
metaKey: true,
|
||||
altKey: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPasteShortcut({
|
||||
key: "v",
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("updates the remote clipboard before sending Ctrl+V", () => {
|
||||
const events: string[] = [];
|
||||
const client: GuacamoleClipboardClient = {
|
||||
createClipboardStream: vi.fn((mimetype: string) => {
|
||||
events.push(`stream:${mimetype}`);
|
||||
return {
|
||||
sendBlob: () => events.push("blob"),
|
||||
sendEnd: () => events.push("end"),
|
||||
};
|
||||
}),
|
||||
sendKeyEvent: vi.fn((pressed: number, keysym: number) => {
|
||||
events.push(`key:${pressed}:${keysym}`);
|
||||
}),
|
||||
};
|
||||
|
||||
pasteTextToRemote(client, "Firefox clipboard");
|
||||
|
||||
expect(events).toEqual([
|
||||
"stream:text/plain",
|
||||
"blob",
|
||||
"end",
|
||||
"key:1:65507",
|
||||
"key:1:118",
|
||||
"key:0:118",
|
||||
"key:0:65507",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getGuacamoleDisplaySize } from "../../../features/guacamole/guacamole-display-size";
|
||||
|
||||
describe("getGuacamoleDisplaySize", () => {
|
||||
it("requests native pixels and matching DPI for HiDPI RDP", () => {
|
||||
expect(getGuacamoleDisplaySize(1280, 720, "rdp", 2)).toEqual({
|
||||
width: 2560,
|
||||
height: 1440,
|
||||
dpi: 192,
|
||||
pixelRatio: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("scales a configured RDP DPI with the device pixel ratio", () => {
|
||||
expect(getGuacamoleDisplaySize(1000, 600, "rdp", 1.5, 120)).toEqual({
|
||||
width: 1500,
|
||||
height: 900,
|
||||
dpi: 180,
|
||||
pixelRatio: 1.5,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves non-RDP protocols at CSS-pixel dimensions", () => {
|
||||
expect(getGuacamoleDisplaySize(1280, 720, "vnc", 2)).toEqual({
|
||||
width: 1280,
|
||||
height: 720,
|
||||
pixelRatio: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("caps pathological pixel ratios to protect remote session size", () => {
|
||||
expect(getGuacamoleDisplaySize(400, 800, "rdp", 4)).toEqual({
|
||||
width: 1200,
|
||||
height: 2400,
|
||||
dpi: 288,
|
||||
pixelRatio: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
screenToCanvas,
|
||||
canvasToScreen,
|
||||
zoomAroundPoint,
|
||||
} from "../../../../features/homepage/canvas/canvasGeometry";
|
||||
|
||||
const PAN = { x: 100, y: 200 };
|
||||
const ZOOM = 2;
|
||||
|
||||
describe("screenToCanvas", () => {
|
||||
it("inverts canvasToScreen", () => {
|
||||
const canvas = { x: 50, y: 75 };
|
||||
const screen = canvasToScreen(canvas.x, canvas.y, PAN, ZOOM);
|
||||
const back = screenToCanvas(screen.x, screen.y, PAN, ZOOM);
|
||||
expect(back.x).toBeCloseTo(canvas.x);
|
||||
expect(back.y).toBeCloseTo(canvas.y);
|
||||
});
|
||||
|
||||
it("accounts for pan offset", () => {
|
||||
const result = screenToCanvas(100, 200, PAN, 1);
|
||||
expect(result.x).toBe(0);
|
||||
expect(result.y).toBe(0);
|
||||
});
|
||||
|
||||
it("accounts for zoom", () => {
|
||||
const result = screenToCanvas(300, 400, PAN, ZOOM);
|
||||
expect(result.x).toBe((300 - 100) / 2);
|
||||
expect(result.y).toBe((400 - 200) / 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canvasToScreen", () => {
|
||||
it("maps origin with pan offset", () => {
|
||||
const result = canvasToScreen(0, 0, PAN, ZOOM);
|
||||
expect(result.x).toBe(100);
|
||||
expect(result.y).toBe(200);
|
||||
});
|
||||
|
||||
it("scales by zoom", () => {
|
||||
const result = canvasToScreen(10, 20, PAN, ZOOM);
|
||||
expect(result.x).toBe(120);
|
||||
expect(result.y).toBe(240);
|
||||
});
|
||||
});
|
||||
|
||||
describe("zoomAroundPoint", () => {
|
||||
it("keeps the mouse point on the same canvas coordinate", () => {
|
||||
const mouseX = 400;
|
||||
const mouseY = 300;
|
||||
const pan = { x: 0, y: 0 };
|
||||
const oldZoom = 1;
|
||||
const newZoom = 2;
|
||||
|
||||
const newPan = zoomAroundPoint(mouseX, mouseY, pan, oldZoom, newZoom);
|
||||
|
||||
// Canvas coordinate under mouse before zoom
|
||||
const canvasBefore = screenToCanvas(mouseX, mouseY, pan, oldZoom);
|
||||
// Canvas coordinate under mouse after zoom with newPan
|
||||
const canvasAfter = screenToCanvas(mouseX, mouseY, newPan, newZoom);
|
||||
|
||||
expect(canvasAfter.x).toBeCloseTo(canvasBefore.x);
|
||||
expect(canvasAfter.y).toBeCloseTo(canvasBefore.y);
|
||||
});
|
||||
|
||||
it("returns correct pan for zoom-out", () => {
|
||||
const mouseX = 200;
|
||||
const mouseY = 150;
|
||||
const pan = { x: 50, y: 50 };
|
||||
const oldZoom = 2;
|
||||
const newZoom = 1;
|
||||
|
||||
const newPan = zoomAroundPoint(mouseX, mouseY, pan, oldZoom, newZoom);
|
||||
const canvasBefore = screenToCanvas(mouseX, mouseY, pan, oldZoom);
|
||||
const canvasAfter = screenToCanvas(mouseX, mouseY, newPan, newZoom);
|
||||
|
||||
expect(canvasAfter.x).toBeCloseTo(canvasBefore.x);
|
||||
expect(canvasAfter.y).toBeCloseTo(canvasBefore.y);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
snapToGrid,
|
||||
snapToGridFloor,
|
||||
} from "../../../../features/homepage/canvas/snapToGrid";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
|
||||
describe("snapToGrid", () => {
|
||||
it("snaps to nearest grid multiple", () => {
|
||||
expect(snapToGrid(0)).toBe(0);
|
||||
expect(snapToGrid(GRID_SIZE)).toBe(GRID_SIZE);
|
||||
expect(snapToGrid(GRID_SIZE * 2)).toBe(GRID_SIZE * 2);
|
||||
});
|
||||
|
||||
it("rounds to nearest (up when exactly halfway)", () => {
|
||||
expect(snapToGrid(GRID_SIZE / 2)).toBe(GRID_SIZE);
|
||||
expect(snapToGrid(GRID_SIZE * 1.5)).toBe(GRID_SIZE * 2);
|
||||
});
|
||||
|
||||
it("rounds down when below halfway", () => {
|
||||
expect(snapToGrid(GRID_SIZE * 0.4)).toBe(0);
|
||||
expect(snapToGrid(GRID_SIZE * 1.4)).toBe(GRID_SIZE);
|
||||
});
|
||||
|
||||
it("works with negative values", () => {
|
||||
expect(snapToGrid(-GRID_SIZE)).toBe(-GRID_SIZE);
|
||||
expect(snapToGrid(-GRID_SIZE * 0.4)).toBeCloseTo(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("snapToGridFloor", () => {
|
||||
it("always rounds down to floor multiple", () => {
|
||||
expect(snapToGridFloor(GRID_SIZE - 1)).toBe(0);
|
||||
expect(snapToGridFloor(GRID_SIZE)).toBe(GRID_SIZE);
|
||||
expect(snapToGridFloor(GRID_SIZE + 1)).toBe(GRID_SIZE);
|
||||
expect(snapToGridFloor(GRID_SIZE * 2 - 1)).toBe(GRID_SIZE);
|
||||
});
|
||||
|
||||
it("handles zero", () => {
|
||||
expect(snapToGridFloor(0)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseAsciicast } from "../../../features/session-recording/asciicast";
|
||||
|
||||
describe("parseAsciicast", () => {
|
||||
it("parses terminal dimensions and timed input/output", () => {
|
||||
const recording = parseAsciicast(
|
||||
'{"version":2,"width":120,"height":30}\n[0.1,"o","hello"]\n[1.5,"i","ls\\r"]\n[2,"r","100x40"]\n',
|
||||
);
|
||||
expect(recording.width).toBe(120);
|
||||
expect(recording.height).toBe(30);
|
||||
expect(recording.duration).toBe(2);
|
||||
expect(recording.events).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("rejects unsupported recording versions", () => {
|
||||
expect(() => parseAsciicast('{"version":1}\n')).toThrow(
|
||||
"Unsupported asciicast version",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
|
||||
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
describe("Android IME composition", () => {
|
||||
let terminal: Terminal | undefined;
|
||||
let container: HTMLDivElement | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
terminal?.dispose();
|
||||
container?.remove();
|
||||
terminal = undefined;
|
||||
container = undefined;
|
||||
});
|
||||
|
||||
it("forwards a shorter Vietnamese replacement to the shell", async () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
terminal = new Terminal();
|
||||
terminal.open(container);
|
||||
|
||||
const input: string[] = [];
|
||||
terminal.onData((data) => input.push(data));
|
||||
|
||||
const textarea = terminal.textarea!;
|
||||
textarea.value = "Hoar";
|
||||
textarea.selectionStart = textarea.value.length;
|
||||
textarea.selectionEnd = textarea.value.length;
|
||||
textarea.dispatchEvent(new CompositionEvent("compositionstart"));
|
||||
textarea.dispatchEvent(
|
||||
new CompositionEvent("compositionupdate", { data: "Hoar" }),
|
||||
);
|
||||
await tick();
|
||||
|
||||
textarea.value = "Hỏa";
|
||||
textarea.selectionStart = textarea.value.length;
|
||||
textarea.selectionEnd = textarea.value.length;
|
||||
textarea.dispatchEvent(
|
||||
new CompositionEvent("compositionupdate", { data: "Hỏa" }),
|
||||
);
|
||||
await tick();
|
||||
textarea.dispatchEvent(new CompositionEvent("compositionend"));
|
||||
await tick();
|
||||
|
||||
expect(input.join("")).toBe("\x7f\x7f\x7fỏa");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getNextTerminalFontSize,
|
||||
getTerminalFontZoomDirection,
|
||||
} from "../../../features/terminal/terminal-font-zoom";
|
||||
|
||||
function keyEvent(
|
||||
overrides: Partial<KeyboardEvent>,
|
||||
): Pick<KeyboardEvent, "altKey" | "code" | "ctrlKey" | "key" | "metaKey"> {
|
||||
return {
|
||||
altKey: false,
|
||||
code: "",
|
||||
ctrlKey: false,
|
||||
key: "",
|
||||
metaKey: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("getTerminalFontZoomDirection", () => {
|
||||
it("recognizes Ctrl+Plus and Ctrl+Minus", () => {
|
||||
expect(
|
||||
getTerminalFontZoomDirection(
|
||||
keyEvent({ ctrlKey: true, code: "Equal", key: "+" }),
|
||||
),
|
||||
).toBe(1);
|
||||
expect(
|
||||
getTerminalFontZoomDirection(
|
||||
keyEvent({ ctrlKey: true, code: "Minus", key: "-" }),
|
||||
),
|
||||
).toBe(-1);
|
||||
});
|
||||
|
||||
it("recognizes unshifted equal, numpad, and macOS shortcuts", () => {
|
||||
expect(
|
||||
getTerminalFontZoomDirection(
|
||||
keyEvent({ ctrlKey: true, code: "Equal", key: "=" }),
|
||||
),
|
||||
).toBe(1);
|
||||
expect(
|
||||
getTerminalFontZoomDirection(
|
||||
keyEvent({ ctrlKey: true, code: "NumpadAdd", key: "+" }),
|
||||
),
|
||||
).toBe(1);
|
||||
expect(
|
||||
getTerminalFontZoomDirection(
|
||||
keyEvent({ metaKey: true, code: "NumpadSubtract", key: "-" }),
|
||||
),
|
||||
).toBe(-1);
|
||||
});
|
||||
|
||||
it("ignores unmodified and Alt-modified keys", () => {
|
||||
expect(
|
||||
getTerminalFontZoomDirection(keyEvent({ code: "Equal", key: "+" })),
|
||||
).toBe(0);
|
||||
expect(
|
||||
getTerminalFontZoomDirection(
|
||||
keyEvent({ altKey: true, ctrlKey: true, code: "Minus", key: "-" }),
|
||||
),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNextTerminalFontSize", () => {
|
||||
it("changes the font size one pixel at a time", () => {
|
||||
expect(getNextTerminalFontSize(14, 1)).toBe(15);
|
||||
expect(getNextTerminalFontSize(14, -1)).toBe(13);
|
||||
});
|
||||
|
||||
it("keeps the existing zoom limits", () => {
|
||||
expect(getNextTerminalFontSize(36, 1)).toBe(36);
|
||||
expect(getNextTerminalFontSize(8, -1)).toBe(8);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
getTunnelTypeForMode,
|
||||
getTunnelPortLabels,
|
||||
getTunnelModeDescription,
|
||||
} from "../../../features/tunnel/tunnel-form-utils.js";
|
||||
|
||||
// A fake translate that echoes the key plus any interpolation args so we can
|
||||
// assert which translation key + payload the helpers chose.
|
||||
const t = (key: string, opts?: Record<string, string | number>) =>
|
||||
opts ? `${key}:${JSON.stringify(opts)}` : key;
|
||||
|
||||
describe("getTunnelTypeForMode", () => {
|
||||
it("maps remote to remote and everything else to local", () => {
|
||||
expect(getTunnelTypeForMode("remote")).toBe("remote");
|
||||
expect(getTunnelTypeForMode("local")).toBe("local");
|
||||
expect(getTunnelTypeForMode("dynamic")).toBe("local");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTunnelPortLabels", () => {
|
||||
it("labels client local mode source/endpoint ports", () => {
|
||||
const labels = getTunnelPortLabels("client", "local", t);
|
||||
expect(labels.sourcePortLabel).toBe("tunnels.localPort");
|
||||
expect(labels.endpointPortLabel).toBe("tunnels.remotePort");
|
||||
});
|
||||
|
||||
it("swaps labels for client remote mode", () => {
|
||||
const labels = getTunnelPortLabels("client", "remote", t);
|
||||
expect(labels.sourcePortLabel).toBe("tunnels.remotePort");
|
||||
expect(labels.endpointPortLabel).toBe("tunnels.localPort");
|
||||
});
|
||||
|
||||
it("uses host/endpoint labels in server scope", () => {
|
||||
const labels = getTunnelPortLabels("server", "local", t);
|
||||
expect(labels.sourcePortLabel).toBe("tunnels.currentHostPort");
|
||||
expect(labels.endpointPortLabel).toBe("tunnels.endpointPort");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTunnelModeDescription", () => {
|
||||
const ports = { sourcePort: 8080, endpointPort: 9090 };
|
||||
|
||||
it("selects the client dynamic description with only the source port", () => {
|
||||
const desc = getTunnelModeDescription("client", "dynamic", ports, t);
|
||||
expect(desc).toContain("tunnels.forwardDescriptionClientDynamic");
|
||||
expect(desc).toContain("8080");
|
||||
expect(desc).not.toContain("9090");
|
||||
});
|
||||
|
||||
it("selects the client local description with both ports", () => {
|
||||
const desc = getTunnelModeDescription("client", "local", ports, t);
|
||||
expect(desc).toContain("tunnels.forwardDescriptionClientLocal");
|
||||
expect(desc).toContain("8080");
|
||||
expect(desc).toContain("9090");
|
||||
});
|
||||
|
||||
it("selects the server remote description", () => {
|
||||
const desc = getTunnelModeDescription("server", "remote", ports, t);
|
||||
expect(desc).toContain("tunnels.forwardDescriptionServerRemote");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { useIsMobile } from "../../hooks/use-mobile.js";
|
||||
|
||||
function setViewport(width: number) {
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: width,
|
||||
});
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches: width < 768,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("useIsMobile", () => {
|
||||
it("is true below the 768px breakpoint", () => {
|
||||
setViewport(500);
|
||||
const { result } = renderHook(() => useIsMobile());
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it("is false at or above the breakpoint", () => {
|
||||
setViewport(1024);
|
||||
const { result } = renderHook(() => useIsMobile());
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("treats exactly 768 as not mobile", () => {
|
||||
setViewport(768);
|
||||
const { result } = renderHook(() => useIsMobile());
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
|
||||
import { changeAppLanguage, normalizeLanguageCode } from "../../i18n/i18n";
|
||||
|
||||
describe("i18n language handling", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("normalizes persisted desktop language codes", () => {
|
||||
expect(normalizeLanguageCode("zh_CN")).toBe("zh-CN");
|
||||
expect(normalizeLanguageCode("pt_br")).toBe("pt-BR");
|
||||
expect(normalizeLanguageCode("EN-us")).toBe("en");
|
||||
expect(normalizeLanguageCode("unknown")).toBe("en");
|
||||
});
|
||||
|
||||
it("stores the normalized language after a successful switch", async () => {
|
||||
await expect(changeAppLanguage("zh_CN")).resolves.toBe("zh-CN");
|
||||
expect(localStorage.getItem("i18nextLng")).toBe("zh-CN");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { getBasePath } from "../../lib/base-path.js";
|
||||
|
||||
const win = window as unknown as Record<string, unknown>;
|
||||
|
||||
afterEach(() => {
|
||||
delete win.__TERMIX_BASE_PATH__;
|
||||
});
|
||||
|
||||
describe("getBasePath", () => {
|
||||
it("returns empty string when no runtime override is set (default base)", () => {
|
||||
// Vite test env BASE_URL is "/" which the helper normalizes to "".
|
||||
expect(getBasePath()).toBe("");
|
||||
});
|
||||
|
||||
it("uses the runtime override when present", () => {
|
||||
win.__TERMIX_BASE_PATH__ = "/termix";
|
||||
expect(getBasePath()).toBe("/termix");
|
||||
});
|
||||
|
||||
it("strips a trailing slash from the runtime override", () => {
|
||||
win.__TERMIX_BASE_PATH__ = "/termix/";
|
||||
expect(getBasePath()).toBe("/termix");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { copyToClipboard } from "../../lib/clipboard";
|
||||
|
||||
describe("copyToClipboard", () => {
|
||||
const originalClipboard = navigator.clipboard;
|
||||
|
||||
beforeEach(() => {
|
||||
delete (window as { electronClipboard?: unknown }).electronClipboard;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: originalClipboard,
|
||||
configurable: true,
|
||||
});
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function setClipboard(value: unknown) {
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
it("uses the electron bridge when present", async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(true);
|
||||
(window as { electronClipboard?: unknown }).electronClipboard = {
|
||||
writeText,
|
||||
readText: vi.fn(),
|
||||
};
|
||||
|
||||
const ok = await copyToClipboard("hello");
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(writeText).toHaveBeenCalledWith("hello");
|
||||
});
|
||||
|
||||
it("uses navigator.clipboard when available", async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
setClipboard({ writeText });
|
||||
|
||||
const ok = await copyToClipboard("world");
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(writeText).toHaveBeenCalledWith("world");
|
||||
});
|
||||
|
||||
it("falls back to execCommand when navigator.clipboard is undefined (Brave / non-HTTPS)", async () => {
|
||||
setClipboard(undefined);
|
||||
const execCommand = vi.fn().mockReturnValue(true);
|
||||
(document as unknown as { execCommand: typeof execCommand }).execCommand =
|
||||
execCommand;
|
||||
|
||||
const ok = await copyToClipboard("fallback");
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(execCommand).toHaveBeenCalledWith("copy");
|
||||
});
|
||||
|
||||
it("falls back to execCommand when the async write throws", async () => {
|
||||
setClipboard({
|
||||
writeText: vi.fn().mockRejectedValue(new Error("denied")),
|
||||
});
|
||||
const execCommand = vi.fn().mockReturnValue(true);
|
||||
(document as unknown as { execCommand: typeof execCommand }).execCommand =
|
||||
execCommand;
|
||||
|
||||
const ok = await copyToClipboard("retry");
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(execCommand).toHaveBeenCalledWith("copy");
|
||||
});
|
||||
|
||||
it("returns false when every approach fails", async () => {
|
||||
setClipboard(undefined);
|
||||
(document as unknown as { execCommand: () => boolean }).execCommand =
|
||||
() => {
|
||||
throw new Error("no execCommand");
|
||||
};
|
||||
|
||||
const ok = await copyToClipboard("nope");
|
||||
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getDatabaseTransferUrl } from "../../lib/database-transfer-url";
|
||||
|
||||
const productionLocation = {
|
||||
protocol: "https:",
|
||||
host: "termix.example.com",
|
||||
hostname: "termix.example.com",
|
||||
port: "",
|
||||
};
|
||||
|
||||
describe("getDatabaseTransferUrl", () => {
|
||||
it("uses the embedded backend for Electron without a remote server", () => {
|
||||
expect(
|
||||
getDatabaseTransferUrl("import", {
|
||||
electron: true,
|
||||
configuredServerUrl: null,
|
||||
location: productionLocation,
|
||||
}),
|
||||
).toBe("http://localhost:30001/database/import");
|
||||
});
|
||||
|
||||
it("uses the configured Electron server without duplicate slashes", () => {
|
||||
expect(
|
||||
getDatabaseTransferUrl("export", {
|
||||
electron: true,
|
||||
configuredServerUrl: "https://termix.example.com/",
|
||||
location: productionLocation,
|
||||
}),
|
||||
).toBe("https://termix.example.com/database/export");
|
||||
});
|
||||
|
||||
it("uses the browser base path in production", () => {
|
||||
expect(
|
||||
getDatabaseTransferUrl("import", {
|
||||
electron: false,
|
||||
location: productionLocation,
|
||||
}),
|
||||
).toBe("https://termix.example.com/database/import");
|
||||
});
|
||||
|
||||
it("uses the backend development port for the Vite frontend", () => {
|
||||
expect(
|
||||
getDatabaseTransferUrl("export", {
|
||||
electron: false,
|
||||
location: {
|
||||
protocol: "http:",
|
||||
host: "localhost:5173",
|
||||
hostname: "localhost",
|
||||
port: "5173",
|
||||
},
|
||||
}),
|
||||
).toBe("http://localhost:30001/database/export");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { installElectronWheelZoomGuard } from "../../lib/electron-wheel-zoom";
|
||||
|
||||
const win = window as unknown as Record<string, unknown>;
|
||||
let cleanup: (() => void) | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
cleanup?.();
|
||||
cleanup = undefined;
|
||||
delete win.IS_ELECTRON;
|
||||
delete win.electronAPI;
|
||||
});
|
||||
|
||||
function dispatchWheel(init?: WheelEventInit): WheelEvent {
|
||||
const event = new WheelEvent("wheel", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...init,
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
describe("installElectronWheelZoomGuard", () => {
|
||||
it("does not block ordinary browser wheel events", () => {
|
||||
cleanup = installElectronWheelZoomGuard();
|
||||
|
||||
expect(dispatchWheel({ ctrlKey: true }).defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks modifier wheel zoom in Electron", () => {
|
||||
win.electronAPI = { isElectron: true };
|
||||
cleanup = installElectronWheelZoomGuard();
|
||||
|
||||
expect(dispatchWheel({ metaKey: true }).defaultPrevented).toBe(true);
|
||||
expect(dispatchWheel({ ctrlKey: true }).defaultPrevented).toBe(true);
|
||||
expect(dispatchWheel().defaultPrevented).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { isElectron } from "../../lib/electron.js";
|
||||
|
||||
const win = window as unknown as Record<string, unknown>;
|
||||
|
||||
afterEach(() => {
|
||||
delete win.IS_ELECTRON;
|
||||
delete win.electronAPI;
|
||||
});
|
||||
|
||||
describe("isElectron", () => {
|
||||
it("returns false in a plain browser window", () => {
|
||||
expect(isElectron()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when IS_ELECTRON flag is set", () => {
|
||||
win.IS_ELECTRON = true;
|
||||
expect(isElectron()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when electronAPI is present", () => {
|
||||
win.electronAPI = {};
|
||||
expect(isElectron()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when electronAPI.isElectron is true", () => {
|
||||
win.electronAPI = { isElectron: true };
|
||||
expect(isElectron()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { apiLogger } from "../../lib/frontend-logger.js";
|
||||
|
||||
// The formatting helpers (status/performance icons, URL sanitization) are
|
||||
// private, so we exercise them through the public request* methods and assert
|
||||
// on what gets written to the console.
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
let groupSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
groupSpy = vi.spyOn(console, "group").mockImplementation(() => {});
|
||||
vi.spyOn(console, "groupEnd").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function allLoggedText(): string {
|
||||
return logSpy.mock.calls
|
||||
.concat(errorSpy.mock.calls)
|
||||
.concat(groupSpy.mock.calls)
|
||||
.map((args) => args.join(" "))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
describe("frontend logger request success", () => {
|
||||
it("includes the success status icon and a fast-performance icon", () => {
|
||||
apiLogger.requestSuccess("get", "https://x.test/api", 200, 50);
|
||||
const text = allLoggedText();
|
||||
expect(text).toContain("✅"); // 2xx status icon
|
||||
expect(text).toContain("⚡"); // <100ms performance icon
|
||||
expect(text).toContain("200");
|
||||
});
|
||||
|
||||
it("uses the slow-performance icon for long requests", () => {
|
||||
apiLogger.requestSuccess("get", "https://x.test/api", 200, 4000);
|
||||
expect(allLoggedText()).toContain("🐌");
|
||||
});
|
||||
});
|
||||
|
||||
describe("frontend logger error mapping", () => {
|
||||
it("uses a client-error icon for 4xx responses", () => {
|
||||
apiLogger.requestError("get", "https://x.test/api", 404, "Not Found", 30);
|
||||
expect(allLoggedText()).toContain("⚠️");
|
||||
});
|
||||
|
||||
it("uses a server-error icon for 5xx responses", () => {
|
||||
apiLogger.requestError("get", "https://x.test/api", 500, "Boom", 30);
|
||||
expect(allLoggedText()).toContain("❌");
|
||||
});
|
||||
});
|
||||
|
||||
describe("frontend logger URL sanitization", () => {
|
||||
it("strips password/token query params from logged URLs", () => {
|
||||
apiLogger.requestStart("get", "https://x.test/login?password=hunter2");
|
||||
const text = allLoggedText();
|
||||
expect(text).not.toContain("hunter2");
|
||||
});
|
||||
|
||||
it("keeps a normal URL intact", () => {
|
||||
apiLogger.requestStart("get", "https://x.test/api/hosts");
|
||||
expect(allLoggedText()).toContain("x.test/api/hosts");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ServerStatusStore } from "../../lib/server-status-store";
|
||||
|
||||
describe("ServerStatusStore", () => {
|
||||
it("notifies only listeners for hosts whose status value changed", () => {
|
||||
const store = new ServerStatusStore();
|
||||
store.setEnabledHostIds(new Set([1, 2, 3]));
|
||||
|
||||
const on1 = vi.fn();
|
||||
const on2 = vi.fn();
|
||||
const onAll = vi.fn();
|
||||
store.subscribeHost(1, on1);
|
||||
store.subscribeHost(2, on2);
|
||||
store.subscribeAll(onAll);
|
||||
|
||||
store.applyStatuses(
|
||||
new Map([
|
||||
[1, { status: "online", lastChecked: "t1" }],
|
||||
[2, { status: "offline", lastChecked: "t1" }],
|
||||
[3, { status: "online", lastChecked: "t1" }],
|
||||
]),
|
||||
);
|
||||
expect(on1).toHaveBeenCalledTimes(1);
|
||||
expect(on2).toHaveBeenCalledTimes(1);
|
||||
expect(onAll).toHaveBeenCalledTimes(1);
|
||||
|
||||
on1.mockClear();
|
||||
on2.mockClear();
|
||||
onAll.mockClear();
|
||||
|
||||
// Only host 2 flips; host 1 lastChecked changes but status stays online.
|
||||
store.applyStatuses(
|
||||
new Map([
|
||||
[1, { status: "online", lastChecked: "t2" }],
|
||||
[2, { status: "online", lastChecked: "t2" }],
|
||||
[3, { status: "online", lastChecked: "t2" }],
|
||||
]),
|
||||
);
|
||||
expect(on1).not.toHaveBeenCalled();
|
||||
expect(on2).toHaveBeenCalledTimes(1);
|
||||
expect(onAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not notify when the status map is unchanged", () => {
|
||||
const store = new ServerStatusStore();
|
||||
store.setEnabledHostIds(new Set([1]));
|
||||
store.applyStatuses(
|
||||
new Map([[1, { status: "online", lastChecked: "t1" }]]),
|
||||
);
|
||||
|
||||
const on1 = vi.fn();
|
||||
store.subscribeHost(1, on1);
|
||||
store.applyStatuses(
|
||||
new Map([[1, { status: "online", lastChecked: "t9" }]]),
|
||||
);
|
||||
expect(on1).not.toHaveBeenCalled();
|
||||
expect(store.getStatus(1)).toBe("online");
|
||||
});
|
||||
|
||||
it("returns offline for disabled hosts", () => {
|
||||
const store = new ServerStatusStore();
|
||||
store.applyStatuses(new Map([[5, { status: "online", lastChecked: "t" }]]));
|
||||
expect(store.getStatus(5)).toBe("offline");
|
||||
store.setEnabledHostIds(new Set([5]));
|
||||
expect(store.getStatus(5)).toBe("online");
|
||||
});
|
||||
|
||||
it("emits meta listeners when initial load completes", () => {
|
||||
const store = new ServerStatusStore();
|
||||
const onMeta = vi.fn();
|
||||
store.subscribeMeta(onMeta);
|
||||
store.setInitialLoadComplete(true);
|
||||
expect(onMeta).toHaveBeenCalledTimes(1);
|
||||
store.setInitialLoadComplete(true);
|
||||
expect(onMeta).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,329 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { highlightTerminalOutput } from "../../lib/terminal-syntax-highlighter.js";
|
||||
|
||||
const ESC = "\x1b";
|
||||
|
||||
describe("highlightTerminalOutput", () => {
|
||||
it("returns empty/whitespace text unchanged", () => {
|
||||
expect(highlightTerminalOutput("")).toBe("");
|
||||
expect(highlightTerminalOutput(" ")).toBe(" ");
|
||||
});
|
||||
|
||||
it("wraps ERROR in bright red", () => {
|
||||
const out = highlightTerminalOutput("Something ERROR happened");
|
||||
expect(out).toContain(ESC + "[91m");
|
||||
expect(out).toContain("ERROR");
|
||||
});
|
||||
|
||||
it("wraps lowercase error in bright red (case-insensitive)", () => {
|
||||
const out = highlightTerminalOutput("something error happened");
|
||||
expect(out).toContain(ESC + "[91m");
|
||||
});
|
||||
|
||||
it("wraps Error (mixed case) in bright red", () => {
|
||||
const out = highlightTerminalOutput("Error: connection refused");
|
||||
expect(out).toContain(ESC + "[91m");
|
||||
});
|
||||
|
||||
it("wraps warn (lowercase) in bright yellow", () => {
|
||||
const out = highlightTerminalOutput("warn: disk almost full");
|
||||
expect(out).toContain(ESC + "[93m");
|
||||
});
|
||||
|
||||
it("wraps warning (lowercase) in bright yellow", () => {
|
||||
const out = highlightTerminalOutput("warning: something is off");
|
||||
expect(out).toContain(ESC + "[93m");
|
||||
});
|
||||
|
||||
it("highlights IPv4 addresses in magenta", () => {
|
||||
const out = highlightTerminalOutput("connect to 192.168.1.10 now");
|
||||
expect(out).toContain("192.168.1.10");
|
||||
expect(out).toContain(ESC + "[35m");
|
||||
});
|
||||
|
||||
it("leaves text with no matchable tokens unchanged", () => {
|
||||
const plain = "just a normal sentence here";
|
||||
expect(highlightTerminalOutput(plain)).toBe(plain);
|
||||
});
|
||||
|
||||
it("leaves text ending in an incomplete ANSI escape untouched", () => {
|
||||
const partial = `loading${ESC}[`;
|
||||
expect(highlightTerminalOutput(partial)).toBe(partial);
|
||||
});
|
||||
|
||||
it("skips TUI cursor-positioning frames (nano/vim protection)", () => {
|
||||
const tuiFrame = `${ESC}[H${ESC}[2J some nano content`;
|
||||
expect(highlightTerminalOutput(tuiFrame)).toBe(tuiFrame);
|
||||
});
|
||||
|
||||
it("skips private TUI mode sequences", () => {
|
||||
const tuiFrame = `${ESC}[?1049h${ESC}[?25lERROR at /var/log/app.log`;
|
||||
expect(highlightTerminalOutput(tuiFrame)).toBe(tuiFrame);
|
||||
});
|
||||
|
||||
it("skips bracketed paste mode sequences", () => {
|
||||
const chunk = `${ESC}[?2004hERROR: enabled paste mode`;
|
||||
expect(highlightTerminalOutput(chunk)).toBe(chunk);
|
||||
});
|
||||
|
||||
it("skips TUI frames with box-drawing characters", () => {
|
||||
const chunk = `┌─ /var/log ─┐\n│ ERROR file │`;
|
||||
expect(highlightTerminalOutput(chunk)).toBe(chunk);
|
||||
});
|
||||
|
||||
it("skips DEC line-drawing charset frames", () => {
|
||||
const chunk = `${ESC}(0lqq /var/log qk${ESC}(B`;
|
||||
expect(highlightTerminalOutput(chunk)).toBe(chunk);
|
||||
});
|
||||
|
||||
it("skips OSC current-directory sequences used by fish prompts", () => {
|
||||
const chunk = `${ESC}]7;file://server/home/user/docker/sonarr\x07user@server ~/docker/sonarr> `;
|
||||
expect(highlightTerminalOutput(chunk)).toBe(chunk);
|
||||
});
|
||||
|
||||
it("skips OSC title sequences", () => {
|
||||
const chunk = `${ESC}]0;user@server:/var/log\x07ERROR after title`;
|
||||
expect(highlightTerminalOutput(chunk)).toBe(chunk);
|
||||
});
|
||||
|
||||
it("highlights text that already has ANSI codes", () => {
|
||||
let heavy = "";
|
||||
for (let i = 0; i < 12; i++) heavy += `${ESC}[32mgreen${ESC}[0m `;
|
||||
heavy += "ERROR at line 5";
|
||||
const out = highlightTerminalOutput(heavy);
|
||||
expect(out).toContain(ESC + "[91m");
|
||||
});
|
||||
|
||||
it("does not process lines exceeding MAX_LINE_LENGTH", () => {
|
||||
const huge = "ERROR " + "x".repeat(3000);
|
||||
expect(highlightTerminalOutput(huge)).toBe(huge);
|
||||
});
|
||||
|
||||
it("highlights simple absolute paths in cyan", () => {
|
||||
const out = highlightTerminalOutput("log at /var/log/nginx/access.log");
|
||||
expect(out).toContain(ESC + "[36m");
|
||||
expect(out).toContain("/var/log/nginx/access.log");
|
||||
});
|
||||
|
||||
it("highlights absolute paths with glob wildcards in cyan", () => {
|
||||
const out = highlightTerminalOutput("files: /usr/share/doc/*/copyright");
|
||||
expect(out).toContain(ESC + "[36m");
|
||||
expect(out).toContain("/usr/share/doc/*/copyright");
|
||||
});
|
||||
|
||||
it("highlights home paths in cyan", () => {
|
||||
const out = highlightTerminalOutput("file: ~/documents/notes.txt");
|
||||
expect(out).toContain(ESC + "[36m");
|
||||
expect(out).toContain("~/documents/notes.txt");
|
||||
});
|
||||
|
||||
it("highlights bracket timestamps in bright black", () => {
|
||||
const out = highlightTerminalOutput("[12:34:56] server started");
|
||||
expect(out).toContain(ESC + "[90m");
|
||||
expect(out).toContain("[12:34:56]");
|
||||
});
|
||||
|
||||
it("does not highlight out-of-range bracket timestamps", () => {
|
||||
// [99:99] is not a valid time
|
||||
const out = highlightTerminalOutput("[99:99] something");
|
||||
expect(out).not.toContain(ESC + "[90m");
|
||||
});
|
||||
|
||||
it("highlights ISO date timestamps in bright black", () => {
|
||||
const out = highlightTerminalOutput("2024-01-15 event occurred");
|
||||
expect(out).toContain(ESC + "[90m");
|
||||
expect(out).toContain("2024-01-15");
|
||||
});
|
||||
|
||||
it("does not highlight version strings as ISO dates", () => {
|
||||
// 2.4.0 or 1.2024.3 should not trigger ISO date
|
||||
const out = highlightTerminalOutput("version 2.4.0 released");
|
||||
expect(out).toBe("version 2.4.0 released");
|
||||
});
|
||||
|
||||
it("highlights labeled numbers (port)", () => {
|
||||
const out = highlightTerminalOutput("listening on port 8080");
|
||||
expect(out).toContain(ESC + "[96m");
|
||||
expect(out).toContain("8080");
|
||||
});
|
||||
|
||||
it("highlights labeled numbers (exit)", () => {
|
||||
const out = highlightTerminalOutput("process exited with exit 1");
|
||||
expect(out).toContain(ESC + "[96m");
|
||||
});
|
||||
|
||||
it("highlights labeled numbers (status)", () => {
|
||||
const out = highlightTerminalOutput("returned status 404");
|
||||
expect(out).toContain(ESC + "[96m");
|
||||
});
|
||||
|
||||
it("does not highlight standalone numbers outside labeled context", () => {
|
||||
const out = highlightTerminalOutput("there are 42 files here");
|
||||
expect(out).toBe("there are 42 files here");
|
||||
});
|
||||
|
||||
it("does not highlight 'up' or 'active' as success", () => {
|
||||
const out = highlightTerminalOutput("service is up and running");
|
||||
expect(out).not.toContain(ESC + "[92m");
|
||||
});
|
||||
|
||||
it("highlights unambiguous success keywords (case-insensitive)", () => {
|
||||
const out = highlightTerminalOutput("Test passed successfully");
|
||||
expect(out).toContain(ESC + "[92m");
|
||||
});
|
||||
|
||||
it("highlights URLs with blue+underline", () => {
|
||||
const out = highlightTerminalOutput("visit https://example.com now");
|
||||
expect(out).toContain(ESC + "[34m");
|
||||
expect(out).toContain(ESC + "[4m");
|
||||
});
|
||||
|
||||
it("preserves \\r in CRLF terminal output", () => {
|
||||
const out = highlightTerminalOutput("ERROR occurred\r\n");
|
||||
expect(out).toContain("\r\n");
|
||||
});
|
||||
|
||||
it("processes multi-line text line by line", () => {
|
||||
const text = "some output\nERROR: failed\n";
|
||||
const out = highlightTerminalOutput(text);
|
||||
expect(out).toContain(ESC + "[91m");
|
||||
});
|
||||
|
||||
it("respects category options - disabling logLevels skips error highlight", () => {
|
||||
const out = highlightTerminalOutput("ERROR occurred", { logLevels: false });
|
||||
expect(out).not.toContain(ESC + "[91m");
|
||||
});
|
||||
|
||||
it("respects category options - disabling paths skips path highlight", () => {
|
||||
const out = highlightTerminalOutput("at /usr/share/doc/something/file", {
|
||||
paths: false,
|
||||
});
|
||||
expect(out).not.toContain(ESC + "[36m");
|
||||
});
|
||||
|
||||
it("respects category options - disabling urls skips url highlight", () => {
|
||||
const out = highlightTerminalOutput("see https://example.com", {
|
||||
urls: false,
|
||||
});
|
||||
expect(out).not.toContain(ESC + "[34m");
|
||||
});
|
||||
|
||||
it("respects category options - disabling ipAddresses skips IP highlight", () => {
|
||||
const out = highlightTerminalOutput("connect to 192.168.1.1", {
|
||||
ipAddresses: false,
|
||||
});
|
||||
expect(out).not.toContain(ESC + "[35m");
|
||||
});
|
||||
|
||||
it("respects category options - disabling timestamps skips ISO date highlight", () => {
|
||||
const out = highlightTerminalOutput("2024-01-15 something", {
|
||||
timestamps: false,
|
||||
});
|
||||
expect(out).not.toContain(ESC + "[90m");
|
||||
});
|
||||
|
||||
it("respects category options - disabling numbers skips port highlight", () => {
|
||||
const out = highlightTerminalOutput("listening on port 8080", {
|
||||
numbers: false,
|
||||
});
|
||||
expect(out).not.toContain(ESC + "[96m");
|
||||
});
|
||||
|
||||
it("skips highlighting when chunk contains a mid-line carriage return", () => {
|
||||
// Progress bars and shell prompt redraws use \r to overwrite the current line
|
||||
const chunk = "downloading...\rdownloading [====] 100%";
|
||||
expect(highlightTerminalOutput(chunk)).toBe(chunk);
|
||||
});
|
||||
|
||||
it("still processes CRLF line endings (\\r\\n is fine)", () => {
|
||||
const out = highlightTerminalOutput("ERROR occurred\r\n");
|
||||
expect(out).toContain(ESC + "[91m");
|
||||
});
|
||||
|
||||
it("does not highlight shell prompt lines (user@host:path$)", () => {
|
||||
const prompt = `${ESC}[01;32mpi@raspberrypi${ESC}[00m:${ESC}[01;34m/home/pi${ESC}[00m$ `;
|
||||
const out = highlightTerminalOutput(prompt);
|
||||
expect(out).toBe(prompt);
|
||||
});
|
||||
|
||||
it("does not highlight plain-text shell prompt line", () => {
|
||||
const prompt = "pi@raspberrypi:/home/pi$ ";
|
||||
const out = highlightTerminalOutput(prompt);
|
||||
expect(out).toBe(prompt);
|
||||
});
|
||||
|
||||
it("does not highlight log keywords inside bracketed SSH headings", () => {
|
||||
const out = highlightTerminalOutput("[info@archlinux] command output");
|
||||
expect(out).toBe("[info@archlinux] command output");
|
||||
});
|
||||
|
||||
it("still highlights log keywords after a bracketed SSH heading", () => {
|
||||
const out = highlightTerminalOutput("[warning@host] command ERROR");
|
||||
expect(out).not.toContain(`${ESC}[93mwarning`);
|
||||
expect(out).toContain(`${ESC}[91mERROR`);
|
||||
});
|
||||
|
||||
it("does not highlight 'success' when immediately followed by a path (cd output)", () => {
|
||||
// Some shells print "success~/new/dir" or "success/path" after a cd command
|
||||
const out = highlightTerminalOutput("success~/home/user/projects");
|
||||
expect(out).not.toContain(ESC + "[92m");
|
||||
});
|
||||
|
||||
it("still highlights 'success' when not followed by a path separator", () => {
|
||||
const out = highlightTerminalOutput("Build success: all tests passed");
|
||||
expect(out).toContain(ESC + "[92m");
|
||||
});
|
||||
|
||||
it("highlights output lines but not the prompt in a mixed chunk", () => {
|
||||
const chunk = `ERROR: disk full\npi@raspberrypi:~$ `;
|
||||
const out = highlightTerminalOutput(chunk);
|
||||
// The error line should be highlighted
|
||||
expect(out).toContain(ESC + "[91m");
|
||||
// The prompt line should be unchanged
|
||||
expect(out).toContain("pi@raspberrypi:~$ ");
|
||||
const promptLine = out.split("\n")[1];
|
||||
expect(promptLine).toBe("pi@raspberrypi:~$ ");
|
||||
});
|
||||
|
||||
it("does not highlight paths inside a command-echo line (prompt + command)", () => {
|
||||
// When the shell echoes the user's command, it prefixes the prompt.
|
||||
// The path in the prompt portion (/home/user) must not be highlighted —
|
||||
// the shell already colored it, and re-coloring it causes the doubled-path bug.
|
||||
const echo = "user@host:/home/user$ cd /opt/app/bin/files";
|
||||
const out = highlightTerminalOutput(echo);
|
||||
expect(out).toBe(echo);
|
||||
});
|
||||
|
||||
it("does not highlight paths in colored command-echo lines (root prompt)", () => {
|
||||
const echo = `${ESC}[01;32mroot@host${ESC}[00m:${ESC}[01;34m/home/user${ESC}[00m# cd /opt/app/bin/files`;
|
||||
const out = highlightTerminalOutput(echo);
|
||||
expect(out).toBe(echo);
|
||||
});
|
||||
|
||||
it("does not highlight the last line of a multi-line chunk with no trailing newline", () => {
|
||||
// In a multi-line chunk, the trailing fragment without \n could be a live
|
||||
// readline input line. Injecting ANSI bytes there breaks bash's cursor
|
||||
// arithmetic (causes cursor jump / text shift when using arrow keys).
|
||||
const chunk = "ERROR: disk full\nuser@host:/path$ cd /var/log";
|
||||
const out = highlightTerminalOutput(chunk);
|
||||
// First line (terminated by \n) gets highlighted
|
||||
expect(out.split("\n")[0]).toContain(ESC + "[91m");
|
||||
// Last unterminated fragment is left verbatim
|
||||
expect(out.split("\n")[1]).toBe("user@host:/path$ cd /var/log");
|
||||
});
|
||||
|
||||
it("still highlights a single-line chunk with no trailing newline", () => {
|
||||
// A single-line chunk with no \n is plain command output, not a readline
|
||||
// input fragment — highlight it normally.
|
||||
const out = highlightTerminalOutput("ERROR: something failed");
|
||||
expect(out).toContain(ESC + "[91m");
|
||||
});
|
||||
|
||||
it("highlights all lines when the chunk ends with a newline", () => {
|
||||
// When a chunk ends with \n every line is complete output — highlight them all.
|
||||
const chunk = "ERROR: disk full\nconnect to /var/run/app.sock\n";
|
||||
const out = highlightTerminalOutput(chunk);
|
||||
expect(out.split("\n")[0]).toContain(ESC + "[91m");
|
||||
expect(out.split("\n")[1]).toContain(ESC + "[36m");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createTtlRequestCache } from "../../lib/ttl-request-cache";
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("createTtlRequestCache", () => {
|
||||
it("dedupes concurrent loaders into a single request", async () => {
|
||||
const cache = createTtlRequestCache<string>(5_000);
|
||||
let calls = 0;
|
||||
const loader = vi.fn(async () => {
|
||||
calls += 1;
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
return "ok";
|
||||
});
|
||||
|
||||
const [a, b] = await Promise.all([cache.get(loader), cache.get(loader)]);
|
||||
expect(a).toBe("ok");
|
||||
expect(b).toBe("ok");
|
||||
expect(calls).toBe(1);
|
||||
expect(loader).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("serves cached value within TTL without reloading", async () => {
|
||||
const cache = createTtlRequestCache<number>(10_000);
|
||||
const loader = vi.fn(async () => 42);
|
||||
|
||||
await cache.get(loader);
|
||||
await cache.get(loader);
|
||||
expect(loader).toHaveBeenCalledTimes(1);
|
||||
expect(cache.peek()).toBe(42);
|
||||
});
|
||||
|
||||
it("reloads after TTL expires", async () => {
|
||||
vi.useFakeTimers();
|
||||
const cache = createTtlRequestCache<number>(1_000);
|
||||
const loader = vi.fn(async () => Date.now());
|
||||
|
||||
await cache.get(loader);
|
||||
expect(loader).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_001);
|
||||
await cache.get(loader);
|
||||
expect(loader).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not cache rejected loaders", async () => {
|
||||
const cache = createTtlRequestCache<string>(5_000);
|
||||
const loader = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("boom"))
|
||||
.mockResolvedValueOnce("recovered");
|
||||
|
||||
await expect(cache.get(loader)).rejects.toThrow("boom");
|
||||
await expect(cache.get(loader)).resolves.toBe("recovered");
|
||||
expect(loader).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("invalidate drops the cached entry", async () => {
|
||||
const cache = createTtlRequestCache<string>(5_000);
|
||||
const loader = vi.fn(async () => "v1");
|
||||
|
||||
await cache.get(loader);
|
||||
cache.invalidate();
|
||||
expect(cache.peek()).toBeNull();
|
||||
|
||||
loader.mockResolvedValueOnce("v2");
|
||||
await expect(cache.get(loader)).resolves.toBe("v2");
|
||||
expect(loader).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { cn } from "../../lib/utils.js";
|
||||
|
||||
describe("cn", () => {
|
||||
it("joins class names", () => {
|
||||
expect(cn("a", "b")).toBe("a b");
|
||||
});
|
||||
|
||||
it("drops falsy values", () => {
|
||||
expect(cn("a", false, null, undefined, "b")).toBe("a b");
|
||||
});
|
||||
|
||||
it("merges conflicting tailwind classes, keeping the last", () => {
|
||||
expect(cn("p-2", "p-4")).toBe("p-4");
|
||||
expect(cn("text-red-500", "text-blue-500")).toBe("text-blue-500");
|
||||
});
|
||||
|
||||
it("supports conditional object syntax", () => {
|
||||
expect(cn("base", { active: true, hidden: false })).toBe("base active");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { AdminUserManagePanel } from "../../sidebar/AdminUserManagePanel";
|
||||
import type { AdminUser } from "../../sidebar/AdminManagementSections";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
adminGetUserHosts: vi.fn(async () => [] as unknown[]),
|
||||
adminDeleteUserHost: vi.fn(async () => ({})),
|
||||
adminGetUserCredentials: vi.fn(async () => [] as unknown[]),
|
||||
adminDeleteUserCredential: vi.fn(async () => ({})),
|
||||
adminGetUserSnippets: vi.fn(async () => [] as unknown[]),
|
||||
adminCreateUserSnippet: vi.fn(async () => ({})),
|
||||
adminUpdateUserSnippet: vi.fn(async () => ({})),
|
||||
adminDeleteUserSnippet: vi.fn(async () => ({})),
|
||||
adminResetUserPassword: vi.fn(async () => ({ dataWiped: false })),
|
||||
adminDisableUserTotp: vi.fn(async () => ({})),
|
||||
adminExportUserData: vi.fn(async () => ({})),
|
||||
getSessions: vi.fn(async () => ({ sessions: [] as unknown[] })),
|
||||
revokeSession: vi.fn(async () => ({})),
|
||||
revokeAllUserSessions: vi.fn(async () => ({})),
|
||||
getApiKeys: vi.fn(async () => ({ apiKeys: [] as unknown[] })),
|
||||
createApiKey: vi.fn(async () => ({})),
|
||||
deleteApiKey: vi.fn(async () => ({})),
|
||||
deleteUser: vi.fn(async () => ({})),
|
||||
getUserRoles: vi.fn(async () => ({ roles: [] as unknown[] })),
|
||||
assignRoleToUser: vi.fn(async () => ({})),
|
||||
removeRoleFromUser: vi.fn(async () => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@/main-axios", () => api);
|
||||
|
||||
vi.mock("../../sidebar/HostEditor", () => ({
|
||||
HostEditor: () => <div data-testid="host-editor" />,
|
||||
}));
|
||||
|
||||
vi.mock("../../sidebar/CredentialEditorView", () => ({
|
||||
CredentialEditorView: () => <div data-testid="credential-editor" />,
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
function makeUser(overrides: Partial<AdminUser> = {}): AdminUser {
|
||||
return {
|
||||
id: "u2",
|
||||
username: "bob",
|
||||
isAdmin: false,
|
||||
isOidc: false,
|
||||
passwordHash: "hash",
|
||||
dataUnlocked: true,
|
||||
totpEnabled: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderPanel(
|
||||
user: AdminUser,
|
||||
callbacks: Partial<Record<string, () => void>> = {},
|
||||
) {
|
||||
return render(
|
||||
<AdminUserManagePanel
|
||||
user={user}
|
||||
roles={[]}
|
||||
onBack={callbacks.onBack ?? vi.fn()}
|
||||
onOpenHostTab={callbacks.onOpenHostTab as never}
|
||||
onUserDeleted={callbacks.onUserDeleted ?? vi.fn()}
|
||||
onTotpDisabled={callbacks.onTotpDisabled ?? vi.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(api)) fn.mockClear();
|
||||
});
|
||||
|
||||
describe("AdminUserManagePanel", () => {
|
||||
it("renders all manage tabs and loads the target user's data", async () => {
|
||||
renderPanel(makeUser());
|
||||
|
||||
for (const tab of [
|
||||
"admin.manageTabAccount",
|
||||
"admin.manageTabHosts",
|
||||
"admin.manageTabCredentials",
|
||||
"admin.manageTabSnippets",
|
||||
"admin.manageTabSessions",
|
||||
"admin.manageTabDanger",
|
||||
]) {
|
||||
expect(screen.getByText(tab)).toBeTruthy();
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.adminGetUserHosts).toHaveBeenCalledWith("u2");
|
||||
expect(api.adminGetUserCredentials).toHaveBeenCalledWith("u2");
|
||||
expect(api.adminGetUserSnippets).toHaveBeenCalledWith("u2");
|
||||
expect(api.getUserRoles).toHaveBeenCalledWith("u2");
|
||||
});
|
||||
});
|
||||
|
||||
it("skips data fetches and shows the locked notice when data is locked", async () => {
|
||||
renderPanel(makeUser({ dataUnlocked: false }));
|
||||
|
||||
expect(screen.getByText("admin.dataLockedBadge")).toBeTruthy();
|
||||
|
||||
await userEvent.click(screen.getByText("admin.manageTabHosts"));
|
||||
expect(screen.getByText("admin.dataLockedNotice")).toBeTruthy();
|
||||
expect(screen.queryByText("admin.addHostForUser")).toBeNull();
|
||||
|
||||
expect(api.adminGetUserHosts).not.toHaveBeenCalled();
|
||||
expect(api.adminGetUserCredentials).not.toHaveBeenCalled();
|
||||
expect(api.adminGetUserSnippets).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lists the user's hosts and opens a tab via the connect button", async () => {
|
||||
api.adminGetUserHosts.mockResolvedValueOnce([
|
||||
{
|
||||
id: 7,
|
||||
name: "web-01",
|
||||
username: "root",
|
||||
ip: "10.0.0.5",
|
||||
port: 22,
|
||||
authType: "password",
|
||||
connectionType: "ssh",
|
||||
},
|
||||
]);
|
||||
const onOpenHostTab = vi.fn();
|
||||
renderPanel(makeUser(), { onOpenHostTab });
|
||||
|
||||
await userEvent.click(screen.getByText("admin.manageTabHosts"));
|
||||
await screen.findByText("web-01");
|
||||
|
||||
await userEvent.click(screen.getByTitle("admin.connectToHost"));
|
||||
expect(onOpenHostTab).toHaveBeenCalledTimes(1);
|
||||
expect(onOpenHostTab.mock.calls[0][0]).toMatchObject({
|
||||
id: "7",
|
||||
ip: "10.0.0.5",
|
||||
});
|
||||
});
|
||||
|
||||
it("disables TOTP through the confirm dialog", async () => {
|
||||
const onTotpDisabled = vi.fn();
|
||||
renderPanel(makeUser({ totpEnabled: true }), { onTotpDisabled });
|
||||
|
||||
expect(screen.getByText("admin.totpStatusEnabled")).toBeTruthy();
|
||||
await userEvent.click(screen.getByText("admin.disableTotp"));
|
||||
await userEvent.click(screen.getByText("common.confirm"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.adminDisableUserTotp).toHaveBeenCalledWith("u2");
|
||||
expect(onTotpDisabled).toHaveBeenCalled();
|
||||
});
|
||||
expect(screen.getByText("admin.totpStatusDisabled")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("creates a snippet for the target user", async () => {
|
||||
renderPanel(makeUser());
|
||||
|
||||
await userEvent.click(screen.getByText("admin.manageTabSnippets"));
|
||||
await userEvent.click(screen.getByText("admin.addSnippetForUser"));
|
||||
await userEvent.type(
|
||||
screen.getByPlaceholderText("admin.snippetNamePlaceholder"),
|
||||
"restart",
|
||||
);
|
||||
await userEvent.type(
|
||||
screen.getByPlaceholderText("admin.snippetContentPlaceholder"),
|
||||
"systemctl restart nginx",
|
||||
);
|
||||
await userEvent.click(screen.getByText("common.save"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.adminCreateUserSnippet).toHaveBeenCalledWith("u2", {
|
||||
name: "restart",
|
||||
content: "systemctl restart nginx",
|
||||
folder: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes the user from the danger tab after confirmation", async () => {
|
||||
const onUserDeleted = vi.fn();
|
||||
renderPanel(makeUser(), { onUserDeleted });
|
||||
|
||||
await userEvent.click(screen.getByText("admin.manageTabDanger"));
|
||||
await userEvent.click(screen.getByText("admin.deleteUser"));
|
||||
await userEvent.click(screen.getByText("common.confirm"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.deleteUser).toHaveBeenCalledWith("bob");
|
||||
expect(onUserDeleted).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks deleting admin accounts", async () => {
|
||||
renderPanel(makeUser({ isAdmin: true }));
|
||||
|
||||
await userEvent.click(screen.getByText("admin.manageTabDanger"));
|
||||
const deleteBtn = screen
|
||||
.getByText("admin.deleteUser")
|
||||
.closest("button") as HTMLButtonElement;
|
||||
expect(deleteBtn.disabled).toBe(true);
|
||||
expect(screen.getByText("admin.deleteUserAdminBlocked")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizePath, splitPath } from "../../sidebar/FolderPathPicker.js";
|
||||
|
||||
describe("splitPath", () => {
|
||||
it("splits on canonical separator", () => {
|
||||
expect(splitPath("Homelab / Debian")).toEqual(["Homelab", "Debian"]);
|
||||
});
|
||||
|
||||
it("splits on slash without spaces", () => {
|
||||
expect(splitPath("Homelab/Debian")).toEqual(["Homelab", "Debian"]);
|
||||
});
|
||||
|
||||
it("splits on slash with only leading space", () => {
|
||||
expect(splitPath("Homelab /Debian")).toEqual(["Homelab", "Debian"]);
|
||||
});
|
||||
|
||||
it("splits on slash with only trailing space", () => {
|
||||
expect(splitPath("Homelab/ Debian")).toEqual(["Homelab", "Debian"]);
|
||||
});
|
||||
|
||||
it("handles three levels", () => {
|
||||
expect(splitPath("A/B/C")).toEqual(["A", "B", "C"]);
|
||||
});
|
||||
|
||||
it("filters empty segments from leading/trailing slashes", () => {
|
||||
expect(splitPath("/Debian/")).toEqual(["Debian"]);
|
||||
});
|
||||
|
||||
it("returns single segment for non-slash path", () => {
|
||||
expect(splitPath("Homelab")).toEqual(["Homelab"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizePath", () => {
|
||||
it("normalizes slash-without-spaces to canonical form", () => {
|
||||
expect(normalizePath("Homelab/Debian")).toBe("Homelab / Debian");
|
||||
});
|
||||
|
||||
it("is idempotent on already-canonical input", () => {
|
||||
expect(normalizePath("Homelab / Debian")).toBe("Homelab / Debian");
|
||||
});
|
||||
|
||||
it("normalizes mixed spacing around slash", () => {
|
||||
expect(normalizePath("Homelab /Debian/ Servers")).toBe(
|
||||
"Homelab / Debian / Servers",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns empty string for empty input", () => {
|
||||
expect(normalizePath("")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
createHostEditorForm,
|
||||
buildHostEditorPayload,
|
||||
type HostProtocols,
|
||||
} from "../../sidebar/HostEditorData";
|
||||
|
||||
const sshOnly: HostProtocols = {
|
||||
enableSsh: true,
|
||||
enableRdp: false,
|
||||
enableVnc: false,
|
||||
enableTelnet: false,
|
||||
};
|
||||
|
||||
describe("buildHostEditorPayload auth field isolation", () => {
|
||||
it("only sends the password when authType is password", () => {
|
||||
const form = {
|
||||
...createHostEditorForm(null),
|
||||
authType: "password" as const,
|
||||
password: "hunter2",
|
||||
key: "PRIVATE KEY",
|
||||
keyPassword: "kp",
|
||||
credentialId: "5",
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
|
||||
expect(payload.password).toBe("hunter2");
|
||||
expect(payload.key).toBeNull();
|
||||
expect(payload.keyPassword).toBeNull();
|
||||
expect(payload.credentialId).toBeNull();
|
||||
});
|
||||
|
||||
it("drops the credentialId when switching a cloned host away from credential auth", () => {
|
||||
const form = {
|
||||
...createHostEditorForm(null),
|
||||
authType: "password" as const,
|
||||
password: "newpass",
|
||||
credentialId: "12",
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
|
||||
expect(payload.credentialId).toBeNull();
|
||||
expect(payload.password).toBe("newpass");
|
||||
});
|
||||
|
||||
it("sends credentialId and optional password when authType is credential", () => {
|
||||
const form = {
|
||||
...createHostEditorForm(null),
|
||||
authType: "credential" as const,
|
||||
credentialId: "7",
|
||||
password: "host-specific-password",
|
||||
key: "leftover-key",
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
|
||||
expect(payload.credentialId).toBe(7);
|
||||
expect(payload.password).toBe("host-specific-password");
|
||||
expect(payload.key).toBeNull();
|
||||
});
|
||||
|
||||
it("sends key fields and optional password when authType is key", () => {
|
||||
const form = {
|
||||
...createHostEditorForm(null),
|
||||
authType: "key" as const,
|
||||
key: "MY KEY",
|
||||
keyType: "ssh-ed25519",
|
||||
password: "leftover",
|
||||
credentialId: "3",
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
|
||||
expect(payload.key).toBe("MY KEY");
|
||||
expect(payload.keyType).toBe("ssh-ed25519");
|
||||
expect(payload.password).toBe("leftover");
|
||||
expect(payload.credentialId).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves agentSocketPath in terminalConfig when authType is agent", () => {
|
||||
const form = {
|
||||
...createHostEditorForm(null),
|
||||
authType: "agent" as const,
|
||||
agentSocketPath: "/run/user/1000/gnupg/S.gpg-agent.ssh",
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
const tc = payload.terminalConfig as Record<string, unknown> | null;
|
||||
|
||||
expect(tc?.agentSocketPath).toBe("/run/user/1000/gnupg/S.gpg-agent.ssh");
|
||||
expect(payload.password).toBeNull();
|
||||
expect(payload.key).toBeNull();
|
||||
});
|
||||
|
||||
it("sets agentSocketPath to null in payload when authType is agent but path is empty", () => {
|
||||
const form = {
|
||||
...createHostEditorForm(null),
|
||||
authType: "agent" as const,
|
||||
agentSocketPath: "",
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
const tc = payload.terminalConfig as Record<string, unknown> | null;
|
||||
|
||||
expect(tc?.agentSocketPath).toBeNull();
|
||||
});
|
||||
|
||||
it("nulls out agentSocketPath when switching away from agent auth", () => {
|
||||
const form = {
|
||||
...createHostEditorForm(null),
|
||||
authType: "password" as const,
|
||||
password: "mypass",
|
||||
agentSocketPath: "/run/user/1000/gnupg/S.gpg-agent.ssh",
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
const tc = payload.terminalConfig as Record<string, unknown> | null;
|
||||
|
||||
expect(tc?.agentSocketPath).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves sudo password autofill settings", () => {
|
||||
const form = {
|
||||
...createHostEditorForm(null),
|
||||
sudoPasswordAutoFill: true,
|
||||
sudoPassword: "sudo-secret",
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
const tc = payload.terminalConfig as Record<string, unknown> | null;
|
||||
|
||||
expect(tc?.sudoPasswordAutoFill).toBe(true);
|
||||
expect(tc?.sudoPassword).toBe("sudo-secret");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Host, HostFolder } from "@/types/ui-types";
|
||||
import {
|
||||
resolveHostSortPreferences,
|
||||
sortHostTree,
|
||||
} from "../../sidebar/host-sort";
|
||||
|
||||
function host(name: string, pin = false): Host {
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
ip: `10.0.0.${name.length}`,
|
||||
online: true,
|
||||
pin,
|
||||
} as Host;
|
||||
}
|
||||
|
||||
function names(folder: HostFolder): string[] {
|
||||
return folder.children.map((child) => child.name);
|
||||
}
|
||||
|
||||
describe("sortHostTree", () => {
|
||||
it("sorts pinned and unpinned hosts by name within their groups", () => {
|
||||
const tree: HostFolder = {
|
||||
name: "root",
|
||||
children: [
|
||||
host("gamma"),
|
||||
host("zeta", true),
|
||||
host("beta"),
|
||||
host("alpha", true),
|
||||
],
|
||||
};
|
||||
|
||||
expect(names(sortHostTree(tree, "name-asc", true))).toEqual([
|
||||
"alpha",
|
||||
"zeta",
|
||||
"beta",
|
||||
"gamma",
|
||||
]);
|
||||
expect(names(tree)).toEqual(["gamma", "zeta", "beta", "alpha"]);
|
||||
});
|
||||
|
||||
it("keeps pinned-first independent from the selected base sort", () => {
|
||||
const tree: HostFolder = {
|
||||
name: "root",
|
||||
children: [host("alpha"), host("zeta", true), host("beta")],
|
||||
};
|
||||
|
||||
expect(names(sortHostTree(tree, "name-asc"))).toEqual([
|
||||
"alpha",
|
||||
"beta",
|
||||
"zeta",
|
||||
]);
|
||||
expect(names(sortHostTree(tree, "default", true))).toEqual([
|
||||
"zeta",
|
||||
"alpha",
|
||||
"beta",
|
||||
]);
|
||||
});
|
||||
|
||||
it("applies combined sorting inside folders", () => {
|
||||
const tree: HostFolder = {
|
||||
name: "root",
|
||||
children: [
|
||||
{
|
||||
name: "production",
|
||||
children: [host("beta"), host("alpha", true)],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const sorted = sortHostTree(tree, "name-asc", true);
|
||||
expect(names(sorted.children[0] as HostFolder)).toEqual(["alpha", "beta"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveHostSortPreferences", () => {
|
||||
it("migrates the legacy pinned sort without losing the preference", () => {
|
||||
expect(resolveHostSortPreferences("pinned", null)).toEqual({
|
||||
sortKey: "default",
|
||||
pinnedFirst: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the saved pinned modifier independent from base sorting", () => {
|
||||
expect(resolveHostSortPreferences("name-desc", "true")).toEqual({
|
||||
sortKey: "name-desc",
|
||||
pinnedFirst: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createQuickConnectHost,
|
||||
quickConnectHostToPayload,
|
||||
} from "../../sidebar/quick-connect-host";
|
||||
|
||||
describe("quick connect host", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("preserves password authentication when saving the connection", () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(1234);
|
||||
|
||||
const host = createQuickConnectHost({
|
||||
ip: "server.example.com",
|
||||
port: 2222,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
password: "secret",
|
||||
});
|
||||
|
||||
expect(host.id).toBe("quick-connect-1234");
|
||||
expect(quickConnectHostToPayload(host)).toMatchObject({
|
||||
name: "root@server.example.com",
|
||||
ip: "server.example.com",
|
||||
port: 2222,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
password: "secret",
|
||||
connectionType: "ssh",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps only the selected credential authentication data", () => {
|
||||
const host = createQuickConnectHost({
|
||||
ip: "10.0.0.2",
|
||||
port: 22,
|
||||
username: "deploy",
|
||||
authType: "credential",
|
||||
credentialId: "42",
|
||||
password: "ignored",
|
||||
key: "ignored",
|
||||
});
|
||||
const payload = quickConnectHostToPayload(host);
|
||||
|
||||
expect(payload.credentialId).toBe(42);
|
||||
expect(payload.password).toBeUndefined();
|
||||
expect(payload.key).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Host, HostFolder } from "@/types/ui-types";
|
||||
|
||||
// Mirror of SidebarTree collectVisibleRows for unit coverage without exporting
|
||||
// the full React module graph.
|
||||
function isFolder(item: Host | HostFolder): item is HostFolder {
|
||||
return "children" in item;
|
||||
}
|
||||
|
||||
function hostMatchesQuery(host: Host, query: string): boolean {
|
||||
const q = query.toLowerCase();
|
||||
return (
|
||||
host.name.toLowerCase().includes(q) ||
|
||||
host.ip.toLowerCase().includes(q) ||
|
||||
host.username.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
function folderHasMatch(folder: HostFolder, query: string): boolean {
|
||||
if (folder.name.toLowerCase().includes(query.toLowerCase())) return true;
|
||||
for (const child of folder.children) {
|
||||
if (isFolder(child)) {
|
||||
if (folderHasMatch(child, query)) return true;
|
||||
} else if (hostMatchesQuery(child, query)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
type VirtualRow = { item: Host | HostFolder; depth: number };
|
||||
|
||||
function collectVisibleRows(
|
||||
children: (Host | HostFolder)[],
|
||||
query: string,
|
||||
openSet: Set<string>,
|
||||
out: VirtualRow[] = [],
|
||||
depth = 0,
|
||||
): VirtualRow[] {
|
||||
for (const child of children) {
|
||||
if (isFolder(child)) {
|
||||
const visible = query ? folderHasMatch(child, query) : true;
|
||||
if (!visible) continue;
|
||||
out.push({ item: child, depth });
|
||||
const childOpen = query ? true : openSet.has(child.path ?? child.name);
|
||||
if (childOpen)
|
||||
collectVisibleRows(child.children, query, openSet, out, depth + 1);
|
||||
} else {
|
||||
if (!query || hostMatchesQuery(child, query))
|
||||
out.push({ item: child, depth });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function host(id: string, name: string): Host {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
username: "u",
|
||||
ip: "10.0.0." + id,
|
||||
port: 22,
|
||||
folder: "",
|
||||
online: true,
|
||||
cpu: null,
|
||||
ram: null,
|
||||
lastAccess: "",
|
||||
tags: [],
|
||||
authType: "password",
|
||||
pin: false,
|
||||
enableSsh: true,
|
||||
enableTerminal: true,
|
||||
enableTunnel: false,
|
||||
enableFileManager: true,
|
||||
enableDocker: false,
|
||||
enableRdp: false,
|
||||
enableVnc: false,
|
||||
enableTelnet: false,
|
||||
quickActions: [],
|
||||
} as Host;
|
||||
}
|
||||
|
||||
describe("collectVisibleRows", () => {
|
||||
const tree: (Host | HostFolder)[] = [
|
||||
host("1", "root-host"),
|
||||
{
|
||||
name: "prod",
|
||||
path: "prod",
|
||||
children: [
|
||||
host("2", "web"),
|
||||
{
|
||||
name: "db",
|
||||
path: "prod / db",
|
||||
children: [host("3", "postgres")],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
it("collapses closed folders", () => {
|
||||
const rows = collectVisibleRows(tree, "", new Set());
|
||||
expect(
|
||||
rows.map((r) => (isFolder(r.item) ? r.item.name : r.item.name)),
|
||||
).toEqual(["root-host", "prod"]);
|
||||
});
|
||||
|
||||
it("expands open folders with depth", () => {
|
||||
const rows = collectVisibleRows(tree, "", new Set(["prod", "prod / db"]));
|
||||
expect(
|
||||
rows.map((r) => ({
|
||||
name: isFolder(r.item) ? r.item.name : r.item.name,
|
||||
depth: r.depth,
|
||||
})),
|
||||
).toEqual([
|
||||
{ name: "root-host", depth: 0 },
|
||||
{ name: "prod", depth: 0 },
|
||||
{ name: "web", depth: 1 },
|
||||
{ name: "db", depth: 1 },
|
||||
{ name: "postgres", depth: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("opens all matching folders when searching", () => {
|
||||
const rows = collectVisibleRows(tree, "postgres", new Set());
|
||||
expect(
|
||||
rows.map((r) => (isFolder(r.item) ? r.item.name : r.item.name)),
|
||||
).toEqual(["prod", "db", "postgres"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user