release-2.4.0 (#913)

* feat: continued adding features to the release.yml

* feat: added auto youtube submit

* feat: add dry run to release workflow

* feat: improve release workflow mode system with new dropdown and overwrite release mode

* fix: make credentials start collapsed

* fix: make hostname fill entire column and truncate at proper spot in dashboard

* fix: guacamole display using an incorrect height

* feat: add folder management (nested folders, folder icons, folder colors, improved folder selection, etc.)

* feat: move ssh host config outside of top tab bar and into new tab bar visible when on SSH tab

* fix: 2FA failing to disable

* feat: Proxmox guest discovery and import (#881)

* feat(db): add Proxmox host fields and schema migration

Add enableProxmox flag and proxmoxConfig JSON to the hosts table so an
SSH host can be marked as a Proxmox node and carry per-host discovery
settings (default credential, Windows/Docker name patterns, preferred
IP prefixes). Includes addColumnIfNotExists migration entries and the
matching SSHHost / HostData / Host type definitions.

* feat(api): add Proxmox guest discovery endpoint

Add POST /proxmox/discover which connects to a Proxmox node over SSH
using the host's existing stored credentials (no separate API token),
runs pvesh to enumerate cluster guests, and returns VMs and LXC
containers ready to import. Guest IPs are resolved with bounded
concurrency (LXC from net config, QEMU via guest agent) to stay within
the node's SSH MaxSessions; configured preferred prefixes pick the
address when a guest has several. Name patterns flag Windows guests as
RDP and enable Docker. Node names are validated before use in shell
commands and the SSH connection is always closed via try/finally. The
endpoint carries an OpenAPI annotation. Persists the new host fields
across host create/update/select, the normalizers (transformHostResponse)
and the bulk routes, with normalizer test coverage.

* feat(ui): add Proxmox host settings and guest import

Add a Proxmox tab to the host editor (following the Docker tab pattern)
to enable the integration and configure the default credential, name
patterns and preferred IP ranges. Proxmox-enabled hosts show a discover
action in the sidebar that opens an import dialog listing the cluster's
guests with multi-select; the import reuses the host's credential, maps
Windows guests to RDP, and groups imported guests into a folder named
after the Proxmox host. Also exposes the import via the hosts
import/export menu. All user-facing strings are added to the en.json
locale and rendered via i18n. Built with the existing shadcn Select,
dialog and section-card components to match the dark theme.

* feat(ui): add Proxmox to the bulk feature toggle

Proxmox was the only host feature missing from the multi-select
"Features" menu. Enabling it in bulk is special: unlike the other
toggles it needs a credential to be useful, so each host now defaults
its Proxmox credential to the credential already stored on that host
(host.credentialId). Discovery then works right away without picking
one by hand, and per-host settings can still be adjusted afterwards.

- SidebarTree: Enable/Disable Proxmox entries (Boxes icon) in the
  bulk Features dropdown
- host-bulk-routes: enabling Proxmox runs per-host so each host keeps
  its own default credential; existing proxmoxConfig is preserved;
  disabling stays a simple flag flip
- en.json: enableProxmoxFeature / disableProxmoxFeature

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix: reliable OIDC group syncing for admin roles (#882)

- always fetch from the userinfo endpoint to get extra claims (like `groups` from Authelia) instead of skipping it if the id_token was already parsed
- handle cases where the OIDC provider sends groups as a single string, a comma-separated string
- add debug logging

* fix: incorrect guacd screen height

* fix: guacd black screen on connnection

* fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)

* feat: initial server stats rewrite (renamed to host metrics)

* feat: continue renaming and improving host metrics features and UI

* fix: clipboard copy fails in Brave/non-HTTPS, dashboard total credentials stuck at 0, host username ignored when credential attached, cloned host can't switch auth method, file-manager context menu off-screen, file delete affecting inactive tabs, silent delete failure on Windows hosts, iPad host tap does nothing, OIDC custom group claim support

* chore: update dependabot for my app and add auto merge/branch update

* chore: update dependabot for my app and add auto merge/branch update

* Feat/docker release tag (#894)

* fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)

* Revert "fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)"

This reverts commit 4d04559ca5.

* feat(docker): add version tag to production Docker image tags

---------

Co-authored-by: i/o agent <273482092+claw-io@users.noreply.github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>

* fix: disable hardware acceleration on Windows to prevent startup crash (#895)

* fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)

* Revert "fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)"

This reverts commit 4d04559ca5.

* fix: disable hardware acceleration on Windows to prevent startup crash

---------

Co-authored-by: i/o agent <273482092+claw-io@users.noreply.github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>

* chore(deps-dev): bump the dev-patch-updates group across 1 directory with 23 updates (#893)

* fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)

* Revert "fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)"

This reverts commit 4d04559ca5.

* chore(deps-dev): bump the dev-patch-updates group across 1 directory with 23 updates

Bumps the dev-patch-updates group with 22 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@codemirror/autocomplete](https://github.com/codemirror/autocomplete) | `6.20.2` | `6.20.3` |
| [@commitlint/cli](https://github.com/conventional-changelog/commitlint/tree/HEAD/@commitlint/cli) | `21.0.1` | `21.0.2` |
| [@commitlint/config-conventional](https://github.com/conventional-changelog/commitlint/tree/HEAD/@commitlint/config-conventional) | `21.0.1` | `21.0.2` |
| [@radix-ui/react-accordion](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/accordion) | `1.2.12` | `1.2.13` |
| [@radix-ui/react-alert-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/alert-dialog) | `1.1.15` | `1.1.16` |
| [@radix-ui/react-checkbox](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/checkbox) | `1.3.3` | `1.3.4` |
| [@radix-ui/react-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dialog) | `1.1.15` | `1.1.16` |
| [@radix-ui/react-dropdown-menu](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dropdown-menu) | `2.1.16` | `2.1.17` |
| [@radix-ui/react-label](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/label) | `2.1.8` | `2.1.9` |
| [@radix-ui/react-popover](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/popover) | `1.1.15` | `1.1.16` |
| [@radix-ui/react-progress](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/progress) | `1.1.8` | `1.1.9` |
| [@radix-ui/react-scroll-area](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/scroll-area) | `1.2.10` | `1.2.11` |
| [@radix-ui/react-separator](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/separator) | `1.1.8` | `1.1.9` |
| [@radix-ui/react-tabs](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tabs) | `1.1.13` | `1.1.14` |
| [@radix-ui/react-tooltip](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tooltip) | `1.2.8` | `1.2.9` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.9.1` | `25.9.2` |
| [i18next](https://github.com/i18next/i18next) | `26.3.0` | `26.3.1` |
| [lint-staged](https://github.com/lint-staged/lint-staged) | `17.0.5` | `17.0.7` |
| [react](https://github.com/facebook/react/tree/HEAD/packages/react) | `19.2.6` | `19.2.7` |
| [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) | `19.2.15` | `19.2.17` |
| [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) | `19.2.6` | `19.2.7` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.0.14` | `8.0.16` |



Updates `@codemirror/autocomplete` from 6.20.2 to 6.20.3
- [Changelog](https://github.com/codemirror/autocomplete/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/autocomplete/commits)

Updates `@commitlint/cli` from 21.0.1 to 21.0.2
- [Release notes](https://github.com/conventional-changelog/commitlint/releases)
- [Changelog](https://github.com/conventional-changelog/commitlint/blob/master/@commitlint/cli/CHANGELOG.md)
- [Commits](https://github.com/conventional-changelog/commitlint/commits/v21.0.2/@commitlint/cli)

Updates `@commitlint/config-conventional` from 21.0.1 to 21.0.2
- [Release notes](https://github.com/conventional-changelog/commitlint/releases)
- [Changelog](https://github.com/conventional-changelog/commitlint/blob/master/@commitlint/config-conventional/CHANGELOG.md)
- [Commits](https://github.com/conventional-changelog/commitlint/commits/v21.0.2/@commitlint/config-conventional)

Updates `@radix-ui/react-accordion` from 1.2.12 to 1.2.13
- [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.15 to 1.1.16
- [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.3 to 1.3.4
- [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.15 to 1.1.16
- [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.16 to 2.1.17
- [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.8 to 2.1.9
- [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.15 to 1.1.16
- [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.8 to 1.1.9
- [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.10 to 1.2.11
- [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-separator` from 1.1.8 to 1.1.9
- [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-slot` from 1.2.4 to 1.2.5
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slot/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slot)

Updates `@radix-ui/react-tabs` from 1.1.13 to 1.1.14
- [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.8 to 1.2.9
- [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 `@types/node` from 25.9.1 to 25.9.2
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `i18next` from 26.3.0 to 26.3.1
- [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.0...v26.3.1)

Updates `lint-staged` from 17.0.5 to 17.0.7
- [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.5...v17.0.7)

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

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

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

Updates `vite` from 8.0.14 to 8.0.16
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.16/packages/vite)

---
updated-dependencies:
- dependency-name: "@codemirror/autocomplete"
  dependency-version: 6.20.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@commitlint/cli"
  dependency-version: 21.0.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@commitlint/config-conventional"
  dependency-version: 21.0.2
  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.13
  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.16
  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.4
  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.16
  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.17
  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.9
  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.16
  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.9
  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.11
  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.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-slot"
  dependency-version: 1.2.5
  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.14
  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.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@types/node"
  dependency-version: 25.9.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: i18next
  dependency-version: 26.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: lint-staged
  dependency-version: 17.0.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: react
  dependency-version: 19.2.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@types/react"
  dependency-version: 19.2.17
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: react-dom
  dependency-version: 19.2.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: vite
  dependency-version: 8.0.16
  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: i/o agent <273482092+claw-io@users.noreply.github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump undici from 7.26.0 to 8.4.0 (#891)

* fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)

* Revert "fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)"

This reverts commit 4d04559ca5.

* chore(deps): bump undici from 7.26.0 to 8.4.0

Bumps [undici](https://github.com/nodejs/undici) from 7.26.0 to 8.4.0.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.26.0...v8.4.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 8.4.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: i/o agent <273482092+claw-io@users.noreply.github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump the prod-minor-updates group with 2 updates (#890)

* fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)

* Revert "fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)"

This reverts commit 4d04559ca5.

* chore(deps): bump the prod-minor-updates group with 2 updates

Bumps the prod-minor-updates group with 2 updates: [axios](https://github.com/axios/axios) and [js-yaml](https://github.com/nodeca/js-yaml).


Updates `axios` from 1.16.1 to 1.17.0
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.16.1...v1.17.0)

Updates `js-yaml` from 4.1.1 to 4.2.0
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/commits)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.17.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: js-yaml
  dependency-version: 4.2.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: i/o agent <273482092+claw-io@users.noreply.github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump actions/setup-node from 4 to 6 (#887)

* fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)

* Revert "fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)"

This reverts commit 4d04559ca5.

* chore(deps): bump actions/setup-node from 4 to 6

Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 6.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: i/o agent <273482092+claw-io@users.noreply.github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump actions/checkout from 4 to 6 (#886)

* fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)

* Revert "fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)"

This reverts commit 4d04559ca5.

* chore(deps): bump actions/checkout from 4 to 6

Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

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

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: i/o agent <273482092+claw-io@users.noreply.github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump actions/upload-artifact from 4 to 7 (#885)

* fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)

* Revert "fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)"

This reverts commit 4d04559ca5.

* chore(deps): bump actions/upload-artifact from 4 to 7

Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: i/o agent <273482092+claw-io@users.noreply.github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump actions/download-artifact from 4 to 8 (#884)

* fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)

* Revert "fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)"

This reverts commit 4d04559ca5.

* chore(deps): bump actions/download-artifact from 4 to 8

Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v8)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: i/o agent <273482092+claw-io@users.noreply.github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore: automate rebasing dependabot

* feat: add admin settings audit log feature

* feat: add customizability of app rail tabs

* fix: host metrics giving sudo password error

* feat: add terminal session logging

* feat: sso system redesign (multiple oidc, ldap support, etc.)

* feat: tailscale auth support

* feat: improve syntax highlighting system

* feat: improve nested folder system/ui

* feat: improve nested folder system/ui

* fix: move tailscale i18n and add scrollbar to ssh host manager customization

* feat: improve syntax highlighting, fix tailscale bugs and host manger UX

* fix: admin page not loading user admin information

* chore: update crowdin workflow

* fix: guacd getting passed incorrect protocol for app view

* fix: add SFTP jump-host fallback from host data (#902)

* fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)

* Revert "fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)"

This reverts commit 4d04559ca5.

* fix: add SFTP jump-host fallback from host data

---------

Co-authored-by: i/o agent <273482092+claw-io@users.noreply.github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>

* feat(tmux-monitor): add per-host tmux session monitor (#896)

* fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)

* Revert "fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)"

This reverts commit 4d04559ca5.

* feat(tmux-monitor): add per-host tmux session monitor

New isolated feature module following the docker/server-stats pattern:

- Backend Express service on port 30010 (src/backend/ssh/tmux-monitor.ts)
  listing tmux sessions/windows/panes, pane capture, cross-session
  output search, and per-pane CPU/RAM/GPU metrics over pooled SSH
  connections. Read-only; reuses host-resolver, jump-host-chain,
  connection pool and RBAC access checks.
- tmux_session_tags table for per-user project tags on sessions.
- Frontend fullscreen app at ?view=tmux-monitor with host selector,
  session/window/pane tree, live pane preview (2s polling), search
  bar, metrics display, and tag editing.
- nginx location blocks for /tmux_monitor in Docker deployments.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tmux-monitor): live streaming backend and shell integration

- WebSocket service on port 30011 (tmux-monitor-live.ts) bridging tmux
  control mode (-C attach-session) to the browser: subscribe per pane,
  decoded %output events streamed as JSON, structure-change and detach
  notifications. Pure control-mode parser with 17 unit tests.
- Register tmux_monitor as a singleton shell tab: AppRail entry below
  Network Graph, global Command Palette action, and per-host palette
  action (gated on enableTerminal) that opens with the host preselected.
- nginx WebSocket location /tmux_monitor/live/ for Docker deployments.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tmux-monitor): xterm live preview and UX polish

- Pane preview is now a read-only xterm.js terminal with ANSI colors
  (capture-pane -e via new ansi=1 param) fed by the live WebSocket;
  status chip shows live/connecting/polling and the original 2s REST
  polling remains as automatic fallback.
- useTmuxLive hook: survives React StrictMode remounts (unmounted flag
  reset on mount), force-closes sockets stuck in CONNECTING after 8s so
  retry/fallback always engages, uses import.meta.env.DEV for dev
  detection.
- UX: skeleton loading, error state with retry, actionable empty states,
  persisted host/expanded-session selection, "/" and Escape shortcuts,
  search match highlighting, fresh relative timestamps.

Verified end-to-end in Chrome against a local backend: live chip goes
green and pane output streams in real time over the control-mode bridge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(tmux-monitor): replace control-mode bridge with native PTY attach

The pane preview now embeds the real Terminal component and attaches to
the selected session through the existing terminal WebSocket (new
tmuxAttachSession handshake field in ssh/terminal.ts), making the
preview fully interactive — typing, mouse scrolling and tmux copy-mode
behave exactly like a normal terminal tab. Pane clicks within a session
use the new /focus endpoint (select-window/select-pane) instead of
remounting the terminal.

This removes the entire custom live-streaming stack: the control-mode
WebSocket service on port 30011 (tmux-monitor-live.ts), the control
sequence parser and its tests, the useTmuxLive hook, the nginx
/tmux_monitor/live/ location blocks, and the now-unused live/polling
status chip locale keys.

Verified end-to-end against a dockerized SSH host (Alpine + tmux 3.6):
session tree, interactive preview with ANSI colors, cross-session
search with jump-to-pane, per-pane CPU/RAM metrics, session tags, and
attach-in-new-tab.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tmux-monitor): use printable field separator in tmux format strings

The unit separator control character (\x1f) used to delimit fields in
tmux -F output gets sanitized to "_" by tmux itself — tmux replaces
control characters (and, under non-UTF-8 locales, any multibyte
character) in expanded format output. On such hosts every list-sessions
line came back as a single unsplittable field, so the monitor showed
session names like "build_1781066459_1781066459_0" and windows/panes
failed to parse entirely.

Switch SEP to the printable ASCII token "<<TMX>>", which survives every
tmux version and locale. Reproduced and verified against tmux 3.6 on
Alpine (C locale) in a docker container.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tmux-monitor): create sessions and split panes from the monitor

- POST /tmux_monitor/:hostId/sessions creates a detached session
  (tmux new-session -d), starting the tmux server if needed. Session
  names are validated against a conservative charset on both ends
  (tmux forbids ":" and "."), and a duplicate name returns 409 with
  tmux's own message surfaced in the toast.
- POST /tmux_monitor/:hostId/split splits the window containing a pane
  (tmux split-window -h/-v), with the new pane starting in the source
  pane's working directory via -c '#{pane_current_path}'.
- UI: a "+" button next to the host selector opens a name popover
  (Enter or Create); the pane preview header gains split-right and
  split-down buttons. Splits appear instantly in the attached preview
  terminal and a silent overview reload updates the tree; new sessions
  are auto-expanded.

Command syntax verified against tmux 3.6 in a dockerized SSH host.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tmux-monitor): VSCode tmux-manager style tree with window actions

Restyle the session tree after the VSCode tmux manager view:

- Header toolbar: new-session (+) popover and refresh moved next to the
  panel title; the host selector row is back to a plain full-width
  select.
- Session rows gain hover actions — attach (play) and new window (+) —
  revealed with the opacity pattern used by docker's ContainerCard so
  they stay keyboard-focusable, with aria-labels on all icon buttons.
- Window rows are collapsible (chevron, "index: name" with a window
  icon); panes show "index: command" plus the pane's working directory
  in muted text with a full-path tooltip.
- New POST /tmux_monitor/:hostId/windows route runs tmux new-window
  against an exact-match ("=name") shell-escaped target; rejects names
  containing ":"/"."/newline and returns 404 for missing sessions.
- Pane preview header groups the split buttons apart from close with a
  separator.

Reviewed by SA/BA/UX subagent panel; keyboard accessibility and
aria-label findings applied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tmux-monitor): rename/kill sessions, collapse-all, no-flash refresh

- Session rows gain a "…" menu with Rename (dialog, validated name) and
  Kill (destructive confirmation). Rename migrates saved tags to the
  new name and re-attaches an open preview; kill closes the preview and
  drops the session's tags. Both routes use exact-match ("=name")
  shell-escaped targets and map tmux's "can't find session" /
  "duplicate session" errors to 404/409.
- Header gains a VSCode-style collapse-all / expand-all toggle for the
  session tree.
- Refresh UX: manual refresh no longer blanks the tree into skeletons —
  the skeleton is reserved for a host's first load; refresh keeps the
  tree, spins the icon (header and toolbar) and reports failures with a
  toast instead of silently keeping stale data.

tmux command syntax verified against tmux 3.6 in the docker test host.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tmux-monitor): single-row sessions, overlay hover actions, tag dialog

UI cleanup after desktop testing showed the tree looked cluttered:

- Sessions are now a single row. The permanently visible tags row (a
  mostly empty line with a lone tag icon under every session) is gone:
  tags render as inline badges after the name (max 2, then "+n" with a
  full-list tooltip) and editing moved to the "…" menu, which opens a
  dialog like rename.
- Session metrics merged into the right-hand meta label
  ("just now · 0% · 7 MB"). Hover actions (attach, new window, …) are
  stacked over that label in a grid cell with an opacity cross-fade, so
  the row never shifts and the buttons remain keyboard-focusable
  (focus-within reveals them).
- Dropdown items no longer wrap: short labels (Rename / Edit tags /
  Kill), whitespace-nowrap, min-w-36, align="end".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tmux-monitor): kill pane; fix session actions in the desktop app

- Kill pane: hover ✕ on pane rows and a trash button in the preview
  header, both behind a confirmation dialog (kill-pane terminates the
  pane's process; the last pane closes its window). New
  POST /tmux_monitor/:hostId/kill-pane route with pane-id validation.
- Fix attach (▷) doing nothing in the Electron build: window.open of
  internal file:// URLs is denied by the window-open handler, so on
  desktop the button now attaches inline — expands the session and
  selects its active pane in the embedded terminal. Browsers keep the
  open-in-new-tab behavior.
- Enlarge the hover-action hit areas (-m-1 p-1) so clicks land on the
  buttons instead of toggling the session row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tmux-monitor): session hover actions were unclickable

The meta label (time · cpu · mem) fades to opacity-0 on hover, which
turns it into a stacking context painted above its plain-flow sibling —
the invisible label sat on top of the attach/new-window/menu buttons
and swallowed their clicks, so clicking them toggled the session row
instead. Give the actions overlay z-10 and make the label
pointer-events-none.

Reproduced and verified in Chrome with elementFromPoint before/after;
create → rename → kill all work end-to-end through the UI now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tmux-monitor): resizable session tree panel

The tree panel was fixed at w-72; long session names and pane paths had
no room. Its right edge is now a drag handle (200–520px, same pattern
and styling as the AppShell host sidebar), double-click resets to the
default width, and the width persists in localStorage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tmux-monitor): split buttons on pane rows

Pane rows' hover actions gain split-right and split-down next to the
kill button, so a pane can be split straight from the tree. splitPane
now takes the target pane id instead of implicitly using the selected
pane; the preview header passes its own pane through.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tmux-monitor): repair garbled preview after layout changes

External split / kill-pane / new-window actions can leave the attached
preview client with stale borders (the redraw races the xterm size).
Two-part fix:

- After any layout-changing action the monitor nudges the embedded
  terminal (fit + notifyResize + refresh) — the PTY resize forces tmux
  to fully redraw the client.
- The preview header gains a re-attach button that remounts the
  terminal with a fresh PTY as a one-click rescue when the view is
  garbled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tmux-monitor): kill window, compact mode, session status tooltip

- Kill window: hover ✕ on window rows behind a confirmation dialog
  (kills every pane; the last window ends the session). New
  POST /tmux_monitor/:hostId/kill-window route with exact-match target
  and 404 mapping.
- Narrow tree panel (<280px) hides the inline "time · cpu · mem" label,
  which used to crush long session names into "bui…".
- Hovering a session row now shows a status board tooltip (attached
  state, activity, window/pane counts, CPU/RAM/GPU, tags) — the numbers
  stay reachable in compact mode and richer in normal mode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tmux-monitor): keep preview attached after killing the viewed pane

Killing the selected pane (or its window) dumped the user back to the
"select a pane" empty state even though the session was still alive and
the attached terminal had already focused a sibling pane. After the
kill the monitor now refetches the overview and selects the session's
new active pane, so the preview stays on the same attach connection
(same session key → no remount); the empty state only appears when the
whole session died with the pane.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tmux-monitor): require execute permission, audit destructive actions, persist tag mutations

- Mutating endpoints (kill/kill-window/kill-pane/rename/split/focus,
  create session/window) now require "execute" host access like docker;
  read-only shared hosts could previously kill sessions and spawn shells
- Log kill/rename actions to the audit log (same pattern as host.ts)
- Trigger a DB save after tag updates in rename/kill handlers; the dirty
  flag was never set, so a crash could lose or resurrect tags
- Add host_id -> ssh_data FK with ON DELETE CASCADE to tmux_session_tags
  (drizzle schema + SQL migration), with a rebuild migration for
  pre-release tables created without the FK

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tmux-monitor): stop polling and key handling while the tab is hidden, guard stale host responses

- Pass isVisible into TmuxMonitor (like DockerManager/HostMetricsTab);
  the global "/"+Escape keydown listener and both SSH polling loops now
  pause while the tab is mounted but hidden
- Split the overview polling interval out of the host-change effect so
  resuming a tab does not reset state or flash the skeleton
- Ignore overview responses for a host the user has switched away from;
  a slow response could overwrite the selected host's tree
- Format SearchResults/SessionTree/TmuxMonitorApp/SidebarTree with
  prettier (pr-check was failing on these)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tmux-monitor): show an empty state when no SSH hosts exist

With zero hosts the host dropdown opened an empty popover and the
preview area showed the unrelated "select a pane" hint. Disable the
select (its placeholder now says no hosts are available) and show a
central no-hosts message with a pointer to the Host Manager.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tmux-monitor): refresh the host list when hosts change elsewhere

The list was fetched once on mount, but the tab stays mounted forever,
so adding the first host in Host Manager left the monitor stuck on
"no hosts" until the tab was closed and reopened. Listen to the
app-wide termix:hosts-changed event (same as AppShell/HostManager),
keep the current selection when still valid, auto-select the first
host when one appears, and clear the session tree when the selected
host is deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tmux-monitor): keep pane/window actions reachable on a narrow tree

Pane rows kept the split/kill buttons and the "index: command" label at
fixed width, so dragging the panel toward the 200px minimum pushed the
buttons past the right edge; Radix ScrollArea's display:table viewport
wrapper made it worse by stretching every row to the widest pane path,
clipping the window kill button too.

Stack the cpu label and the hover actions in one grid cell (the same
overlay pattern as the session rows), let the command and path labels
truncate, and force the viewport wrapper back to display:block.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tmux-monitor): reskin the session status tooltip as a popover card

The base TooltipContent is the inverted one-line chip (bg-foreground,
sharp corners, arrow), so the multi-row session board inherited a light
surface that washed out its muted-foreground labels and clashed with
every other floating card in the app. Restyle this one instance with the
popover surface tokens and hide the chip arrow (important is needed
because Radix sets display on the arrow svg inline).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tmux-monitor): disable new-session and search when tmux is missing

On a host without tmux the + button still opened the create popover
(submitting just surfaced the backend TMUX_NOT_INSTALLED toast) and
search silently returned "0 matches". Gate both on overview.available
like the neighboring refresh/expand buttons; the search input carries
the unavailable message as its tooltip.

Verified against a real no-tmux host (alpine+sshd container): both
controls disable on it and stay enabled on a host with tmux.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tmux-monitor): restyle floating surfaces to match the square UI

Review feedback on #896: the host selector, the new-session popover,
the session status tooltip and the three kill confirmations rendered
with the stock rounded shadcn look while the rest of the app moved to
square corners, ring borders and blurred dialog overlays. Convert the
kill confirmations from AlertDialog to the already-restyled Dialog and
override the remaining surfaces with rounded-none + ring-border.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style(host-editor): format the tailscale device options with project prettier

The block arrived unformatted in the dev-2.4.0 merge; committing the
reformat separately keeps the tmux-monitor commits free of unrelated
churn.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tmux-monitor): per-host opt-in flag, disabled by default

Review feedback on #896: the tmux actions appeared on every SSH host's
sidebar entry even though most users do not use tmux. Add an
enable_tmux_monitor column (default off) mirroring the enableDocker
plumbing, expose it as an "Enable Tmux Monitor" toggle in the host
editor's Terminal tab, and gate every per-host tmux surface on it:
the sidebar hover action and copy-URL menu item, the command palette
entry, the monitor's host selector, and the tmux-monitor API itself
(refusing like docker.ts does when the feature is off).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tmux-monitor): drop the redundant navbar rail entry

The monitor is per-host opt-in, so the global rail item opened a
host-less view. Hosts with enableTmuxMonitor still expose it via the
host action in the sidebar tree and the command palette.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tmux-monitor): fill the status popover card width

TooltipContent is an inline-flex container, so the inner block sized
to its natural content width and left a dead gutter on the right of
the w-56 card. Stretch it with w-full so the values align to the edge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: remove unused dropdown, seperate 3 line menu, and fix a few small UI bugs

---------

Co-authored-by: i/o agent <273482092+claw-io@users.noreply.github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix: add i18n for tmux

* feat: continue improving terminal highlighint with more options and more consistency

* feat: add per host configuration of history tracking with overarching admin toggle

* feat: add storage preference to user profile settings (local or DB)

* chore: update release notes

* fix: return user password reset code to logs

* fix: missing tailscale errors and i18n

* fix: sso/browser preference fixes

* fix: remove SSL_ENABLED references

* feat: add filter/sort to credentials list and fixed some JSDoc comments

* chore: update release notes

* chore: update readmes

* chore: update readmes and add docs links

* feat: continue improving SSO system

* fix: host metrics bugs

* fix: session logging api endpoint fail

* chore: make electron.yml auto deliver to macos app store

* chore: fix linting issues

* chore: fix linting issues

* chore: lint, format, and bump version to 2.4.0

* chore: crowdin using incorrect api key name

* chore: sync Crowdin translations for 2.4.0

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: XtraLarge <eMail@WilliWerres.de>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: verybadsoldier <364469+verybadsoldier@users.noreply.github.com>
Co-authored-by: i/o agent <273482092+claw-io@users.noreply.github.com>
Co-authored-by: Félix MARQUET <72651575+BreizhHardware@users.noreply.github.com>
Co-authored-by: Rabindra Kumar Meher <rabindrameher116@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jose Tenggren <kf16el42@students.muetkhp.edu.pk>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Kang <78076796+khengyun@users.noreply.github.com>
This commit is contained in:
Luke Gustafson
2026-06-16 15:59:53 -05:00
committed by GitHub
parent 580c284065
commit ac24fdcba8
297 changed files with 111507 additions and 74240 deletions
+177 -18
View File
@@ -18,6 +18,7 @@ import { QuickConnectPanel } from "@/sidebar/QuickConnectPanel";
import { SshToolsPanel } from "@/sidebar/SshToolsPanel";
import { SnippetsPanel } from "@/sidebar/SnippetsPanel";
import { HistoryPanel } from "@/sidebar/HistoryPanel";
import { SessionLogsPanel } from "@/sidebar/SessionLogsPanel";
import { SplitScreenPanel } from "@/sidebar/SplitScreenPanel";
import { UserProfilePanel } from "@/sidebar/UserProfilePanel";
import { AdminSettingsPanel } from "@/sidebar/AdminSettingsPanel";
@@ -38,6 +39,7 @@ import { applyAccentColor, applyFontSize, PANE_COUNTS } from "@/lib/theme";
import { useTheme } from "@/components/theme-provider";
import {
getSSHHosts,
getSSHFolders,
getUserInfo,
getOpenTabs,
addOpenTab,
@@ -80,6 +82,9 @@ function sshHostToHost(h: SSHHostWithStatus): Host {
enableTunnel: h.enableTunnel ?? false,
enableFileManager: h.enableFileManager ?? false,
enableDocker: h.enableDocker ?? false,
enableProxmox: h.enableProxmox ?? false,
enableTmuxMonitor: h.enableTmuxMonitor ?? false, // --- tmux-monitor ---
proxmoxConfig: (h.proxmoxConfig as Host["proxmoxConfig"]) ?? null,
enableRdp: h.enableRdp ?? h.connectionType === "rdp",
enableVnc: h.enableVnc ?? h.connectionType === "vnc",
enableTelnet: h.enableTelnet ?? h.connectionType === "telnet",
@@ -106,7 +111,10 @@ function sshHostToHost(h: SSHHostWithStatus): Host {
};
}
function buildHostTree(hosts: SSHHostWithStatus[]): HostFolder {
function buildHostTree(
hosts: SSHHostWithStatus[],
folderMeta?: Map<string, { color?: string; icon?: string }>,
): HostFolder {
const root: HostFolder = { name: "root", children: [] };
const folderMap = new Map<string, HostFolder>();
const getOrCreateFolder = (path: string): HostFolder => {
@@ -117,7 +125,14 @@ function buildHostTree(hosts: SSHHostWithStatus[]): HostFolder {
for (const part of parts) {
accumulated = accumulated ? `${accumulated} / ${part}` : part;
if (!folderMap.has(accumulated)) {
const folder: HostFolder = { name: part, children: [] };
const meta = folderMeta?.get(accumulated);
const folder: HostFolder = {
name: part,
path: accumulated,
color: meta?.color,
icon: meta?.icon,
children: [],
};
folderMap.set(accumulated, folder);
current.children.push(folder);
}
@@ -125,6 +140,10 @@ function buildHostTree(hosts: SSHHostWithStatus[]): HostFolder {
}
return current;
};
// Surface empty folders (created but with no hosts yet) so they stay visible.
if (folderMeta) {
for (const path of folderMeta.keys()) getOrCreateFolder(path);
}
for (const h of hosts) {
const host = sshHostToHost(h);
if (h.folder) {
@@ -188,7 +207,7 @@ export function AppShell({
const [profileDropdownOpen, setProfileDropdownOpen] = useState(false);
const [sidebarWidth, setSidebarWidth] = useState(() => {
const saved = localStorage.getItem("termix_sidebarWidth");
return saved ? parseInt(saved, 10) : 266;
return saved ? parseInt(saved, 10) : 291;
});
const [sidebarDragging, setSidebarDragging] = useState(false);
const [sidebarEditing, setSidebarEditing] = useState(false);
@@ -274,6 +293,7 @@ export function AppShell({
"ssh-tools": "SSH Tools",
snippets: "Snippets",
history: "History",
"session-logs": t("nav.sessionLogs"),
"split-screen": "Split Screen",
connections: t("nav.connections"),
"user-profile": "User Profile",
@@ -371,15 +391,110 @@ export function AppShell({
getUserPreferences()
.then((prefs) => {
setUserPrefs(prefs);
if (prefs.theme) setTheme(prefs.theme as ThemeId);
if (prefs.fontSize) applyFontSize(prefs.fontSize as FontSizeId);
if (prefs.accentColor) {
localStorage.setItem("termix-accent", prefs.accentColor);
applyAccentColor(prefs.accentColor);
}
if (prefs.language && prefs.language !== i18n.language) {
localStorage.setItem("i18nextLng", prefs.language);
void i18n.changeLanguage(prefs.language);
if (prefs.storageMode === "cloud") {
// Persist the current browser values before overwriting, so any tab can restore them
if (!localStorage.getItem("termix-local-snapshot")) {
const SNAPSHOT_KEYS = [
"termix-accent",
"termix-font-size",
"i18nextLng",
"commandAutocomplete",
"commandPaletteShortcutEnabled",
"showHostTags",
"hostTrayOnClick",
"pinAppRail",
"defaultSnippetFoldersCollapsed",
"confirmSnippetExecution",
"disableUpdateCheck",
"confirmTabClose",
"hiddenRailTabs",
];
const snap: Record<string, string | null> = {
__theme: localStorage.getItem("termix-theme"),
};
for (const key of SNAPSHOT_KEYS)
snap[key] = localStorage.getItem(key);
localStorage.setItem("termix-local-snapshot", JSON.stringify(snap));
}
if (prefs.theme) setTheme(prefs.theme as ThemeId);
if (prefs.fontSize) applyFontSize(prefs.fontSize as FontSizeId);
if (prefs.accentColor) {
localStorage.setItem("termix-accent", prefs.accentColor);
applyAccentColor(prefs.accentColor);
}
if (prefs.language && prefs.language !== i18n.language) {
localStorage.setItem("i18nextLng", prefs.language);
void i18n.changeLanguage(prefs.language);
}
if (
prefs.commandAutocomplete !== null &&
prefs.commandAutocomplete !== undefined
)
localStorage.setItem(
"commandAutocomplete",
String(prefs.commandAutocomplete),
);
if (
prefs.commandPaletteEnabled !== null &&
prefs.commandPaletteEnabled !== undefined
)
localStorage.setItem(
"commandPaletteShortcutEnabled",
String(prefs.commandPaletteEnabled),
);
if (prefs.showHostTags !== null && prefs.showHostTags !== undefined) {
localStorage.setItem("showHostTags", String(prefs.showHostTags));
window.dispatchEvent(new CustomEvent("showHostTagsChanged"));
}
if (
prefs.hostTrayOnClick !== null &&
prefs.hostTrayOnClick !== undefined
)
localStorage.setItem(
"hostTrayOnClick",
String(prefs.hostTrayOnClick),
);
if (prefs.pinAppRail !== null && prefs.pinAppRail !== undefined)
localStorage.setItem("pinAppRail", String(prefs.pinAppRail));
if (
prefs.foldersCollapsed !== null &&
prefs.foldersCollapsed !== undefined
)
localStorage.setItem(
"defaultSnippetFoldersCollapsed",
String(prefs.foldersCollapsed),
);
if (
prefs.confirmSnippetExecution !== null &&
prefs.confirmSnippetExecution !== undefined
)
localStorage.setItem(
"confirmSnippetExecution",
String(prefs.confirmSnippetExecution),
);
if (
prefs.disableUpdateCheck !== null &&
prefs.disableUpdateCheck !== undefined
)
localStorage.setItem(
"disableUpdateCheck",
String(prefs.disableUpdateCheck),
);
if (
prefs.confirmTabClose !== null &&
prefs.confirmTabClose !== undefined
)
localStorage.setItem(
"confirmTabClose",
String(prefs.confirmTabClose),
);
if (
prefs.hiddenRailTabs !== null &&
prefs.hiddenRailTabs !== undefined
) {
localStorage.setItem("hiddenRailTabs", prefs.hiddenRailTabs);
window.dispatchEvent(new CustomEvent("hiddenRailTabsChanged"));
}
}
})
.catch(() => {})
@@ -389,10 +504,20 @@ export function AppShell({
// Load real hosts from API
const loadHosts = useCallback(async () => {
try {
const raw = await getSSHHosts();
const [raw, folders] = await Promise.all([
getSSHHosts(),
getSSHFolders().catch(() => []),
]);
const converted = raw.map(sshHostToHost);
setAllHosts(converted);
setRealHostTree(buildHostTree(raw));
const folderMeta = new Map<string, { color?: string; icon?: string }>();
for (const f of folders) {
folderMeta.set(f.name, {
color: f.color ?? undefined,
icon: f.icon ?? undefined,
});
}
setRealHostTree(buildHostTree(raw, folderMeta));
} catch {
// Keep empty state on error
} finally {
@@ -442,7 +567,7 @@ export function AppShell({
"telnet",
"files",
"docker",
"stats",
"host-metrics",
"tunnel",
];
@@ -637,11 +762,22 @@ export function AppShell({
: host.enableTelnet
? "telnet"
: "terminal");
// --- tmux-monitor --- singleton tab, not a per-host tab
if (type === "tmux_monitor") {
openSingletonTab(type, undefined, host);
return;
}
openTab(host, type);
}
const openSingletonTab = useCallback(
function openSingletonTab(type: TabType, pendingEvent?: string) {
// --- tmux-monitor --- (added optional `host` so tmux_monitor can open
// with a preselected host; existing callers are unaffected)
function openSingletonTab(
type: TabType,
pendingEvent?: string,
host?: Host,
) {
if (type === "host-manager") {
if (pendingEvent === "host-manager:add-credential") {
setSidebarOpen(true);
@@ -677,9 +813,15 @@ export function AppShell({
docker: t("nav.docker"),
tunnel: t("nav.tunnels"),
network_graph: t("nav.networkGraph"),
tmux_monitor: t("nav.tmuxMonitor"), // --- tmux-monitor ---
};
setTabs((prev) => {
if (prev.find((t) => t.id === id)) return prev;
const existing = prev.find((t) => t.id === id);
if (existing) {
// --- tmux-monitor --- refocusing with a host preselects it
if (!host) return prev;
return prev.map((t) => (t.id === id ? { ...t, host } : t));
}
return [
...prev,
{
@@ -688,6 +830,7 @@ export function AppShell({
type,
label: singletonLabels[type] ?? type,
openedAt: Date.now(),
...(host ? { host } : {}), // --- tmux-monitor ---
},
];
});
@@ -709,6 +852,9 @@ export function AppShell({
function doCloseTab(id: string) {
const tabToClose = tabs.find((t) => t.id === id);
if (tabToClose?.terminalRef?.current?.disconnect) {
tabToClose.terminalRef.current.disconnect();
}
if (
tabToClose?.instanceId &&
PERSISTENT_TAB_TYPES.includes(tabToClose.type)
@@ -1051,6 +1197,12 @@ export function AppShell({
</div>
)}
{railView === "session-logs" && (
<div className="relative flex-1 min-h-0 flex flex-col">
<SessionLogsPanel />
</div>
)}
{railView === "user-profile" && (
<div className="flex-1 min-h-0 overflow-y-auto">
<UserProfilePanel
@@ -1084,7 +1236,7 @@ export function AppShell({
size="icon"
className="h-full w-12.5 border-y-0 border-border rounded-none text-muted-foreground hover:text-foreground"
title="Reset width"
onClick={() => setSidebarWidth(266)}
onClick={() => setSidebarWidth(291)}
>
<Maximize2 className="size-3.5" />
</Button>
@@ -1269,6 +1421,13 @@ export function AppShell({
].includes(type)
) {
openSingletonTab(type, pendingEvent);
} else if (type === "tmux_monitor") {
// --- tmux-monitor --- singleton tab, optionally preselecting a host
openSingletonTab(
type,
undefined,
label ? allHosts.find((h) => h.name === label) : undefined,
);
} else if (label) {
const host = allHosts.find((h) => h.name === label);
if (host) openTab(host, type);
+66
View File
@@ -0,0 +1,66 @@
import { authApi, handleApiError } from "@/main-axios";
export interface AuditLog {
id: number;
userId: string;
username: string;
action: string;
resourceType: string;
resourceId: string | null;
resourceName: string | null;
details: string | null;
ipAddress: string | null;
userAgent: string | null;
success: boolean;
errorMessage: string | null;
timestamp: string;
}
export interface AuditLogFilters {
page?: number;
limit?: number;
userId?: string;
action?: string;
resourceType?: string;
success?: boolean | "";
startDate?: string;
endDate?: string;
}
export interface AuditLogResponse {
logs: AuditLog[];
total: number;
page: number;
totalPages: number;
}
export async function getAuditLogs(
filters: AuditLogFilters = {},
): Promise<AuditLogResponse> {
try {
const params = new URLSearchParams();
if (filters.page) params.set("page", String(filters.page));
if (filters.limit) params.set("limit", String(filters.limit));
if (filters.userId) params.set("userId", filters.userId);
if (filters.action) params.set("action", filters.action);
if (filters.resourceType) params.set("resourceType", filters.resourceType);
if (filters.success !== undefined && filters.success !== "")
params.set("success", String(filters.success));
if (filters.startDate) params.set("startDate", filters.startDate);
if (filters.endDate) params.set("endDate", filters.endDate);
const response = await authApi.get(`/audit-logs?${params.toString()}`);
return response.data;
} catch (error) {
handleApiError(error, "fetch audit logs");
}
}
export async function getAuditLogActions(): Promise<{ actions: string[] }> {
try {
const response = await authApi.get("/audit-logs/actions");
return response.data;
} catch (error) {
handleApiError(error, "fetch audit log actions");
}
}
+3 -1
View File
@@ -2,7 +2,9 @@ import { authApi, handleApiError, sshHostApi } from "@/main-axios";
import type { SSHFolder } from "@/types/index";
import { sshLogger } from "@/lib/frontend-logger";
export async function getCredentials(): Promise<Record<string, unknown>> {
export async function getCredentials(): Promise<
Record<string, unknown>[] | Record<string, unknown>
> {
try {
const response = await authApi.get("/credentials");
return response.data;
-26
View File
@@ -1,26 +0,0 @@
import { dashboardApi } from "@/main-axios";
export interface DashboardLayout {
cards: Array<{
id: string;
enabled: boolean;
order: number;
panel?: "main" | "side";
height?: number | null;
}>;
mainWidthPct?: number;
}
export async function getDashboardPreferences(): Promise<DashboardLayout> {
const response = await dashboardApi.get("/dashboard/preferences");
return response.data;
}
export async function saveDashboardPreferences(
layout: DashboardLayout,
): Promise<{ success: boolean }> {
const response = await dashboardApi.post("/dashboard/preferences", layout);
return response.data;
}
// ============================================================================
+100
View File
@@ -0,0 +1,100 @@
import { handleApiError, statsApi } from "@/main-axios";
import type { HostMetricsLayout } from "@/types/host-metrics";
/**
* Host Metrics layout persistence (server-synced per user/host) + the manager
* card endpoints. All routes live under the `/host-metrics/*` prefix on the
* stats app (port 30005).
*/
export async function getHostMetricsLayout(
hostId: number,
): Promise<HostMetricsLayout | null> {
try {
const res = await statsApi.get(`/host-metrics/preferences/${hostId}`, {
validateStatus: (status) => status === 200 || status === 404,
});
if (res.status === 404) return null;
return res.data?.layout ?? null;
} catch (error) {
handleApiError(error, "fetch host metrics layout");
throw error;
}
}
export async function saveHostMetricsLayout(
hostId: number,
layout: HostMetricsLayout,
): Promise<void> {
try {
await statsApi.post(`/host-metrics/preferences/${hostId}`, layout);
} catch (error) {
handleApiError(error, "save host metrics layout");
throw error;
}
}
// ─── Managers ───────────────────────────────────────────────────────────────
export interface PlatformInfo {
hasSystemd: boolean;
pkg: "apt" | "dnf" | "yum" | "pacman" | null;
hasCertbot: boolean;
hasAcmeSh: boolean;
hasDocker: boolean;
osPrettyName: string | null;
}
export async function getHostPlatform(hostId: number): Promise<PlatformInfo> {
const res = await statsApi.get(`/host-metrics/platform/${hostId}`);
return res.data;
}
/** GET a manager resource (read). */
export async function managerGet<T>(
hostId: number,
resource: string,
params?: Record<string, string | number>,
): Promise<T> {
const res = await statsApi.get(
`/host-metrics/managers/${resource}/${hostId}`,
{ params },
);
return res.data as T;
}
/**
* GET a manager sub-resource where the host id sits in the middle of the path,
* e.g. /managers/logs/{id}/files.
*/
export async function managerGetSub<T>(
hostId: number,
resource: string,
sub: string,
params?: Record<string, string | number>,
): Promise<T> {
const res = await statsApi.get(
`/host-metrics/managers/${resource}/${hostId}/${sub}`,
{ params },
);
return res.data as T;
}
/**
* POST a manager action. `resource` is the manager name; `action` is the
* optional sub-path (e.g. "action", "signal", "renew"). The host id always
* sits between them: /managers/{resource}/{id}[/{action}].
*/
export async function managerPost<T>(
hostId: number,
resource: string,
body: unknown,
action?: string,
): Promise<T> {
const suffix = action ? `/${action}` : "";
const res = await statsApi.post(
`/host-metrics/managers/${resource}/${hostId}${suffix}`,
body,
);
return res.data as T;
}
+11
View File
@@ -83,6 +83,17 @@ export interface UserPreferences {
fontSize?: string | null;
accentColor?: string | null;
language?: string | null;
storageMode?: string | null;
commandAutocomplete?: boolean | null;
commandPaletteEnabled?: boolean | null;
showHostTags?: boolean | null;
hostTrayOnClick?: boolean | null;
pinAppRail?: boolean | null;
foldersCollapsed?: boolean | null;
confirmSnippetExecution?: boolean | null;
disableUpdateCheck?: boolean | null;
confirmTabClose?: boolean | null;
hiddenRailTabs?: string | null;
}
export async function getUserPreferences(): Promise<UserPreferences> {
+42
View File
@@ -0,0 +1,42 @@
import { authApi, handleApiError } from "@/main-axios";
export type SessionLogRecord = {
id: number;
hostId: number;
userId: string;
startedAt: string;
endedAt: string | null;
duration: number | null;
recordingPath: string | null;
hostName: string | null;
hostIp: string | null;
sizeBytes: number | null;
};
export async function getSessionLogs(): Promise<SessionLogRecord[]> {
try {
const response = await authApi.get("/session_logs/");
return response.data.logs;
} catch (error) {
throw handleApiError(error, "fetch session logs");
}
}
export async function getSessionLogContent(id: number): Promise<string> {
try {
const response = await authApi.get(`/session_logs/${id}/content`, {
responseType: "text",
});
return response.data;
} catch (error) {
throw handleApiError(error, "fetch session log content");
}
}
export async function deleteSessionLog(id: number): Promise<void> {
try {
await authApi.delete(`/session_logs/${id}`);
} catch (error) {
throw handleApiError(error, "delete session log");
}
}
+48
View File
@@ -70,6 +70,54 @@ export async function updateSessionTimeout(
}
}
// ============================================================================
// TAILSCALE SETTINGS
// ============================================================================
export async function getTailscaleSettings(): Promise<{
apiKey: string;
hasApiKey: boolean;
}> {
try {
const response = await authApi.get("/users/tailscale-settings");
return response.data;
} catch (error) {
handleApiError(error, "fetch Tailscale settings");
}
}
export async function updateTailscaleSettings(
apiKey: string,
): Promise<{ hasApiKey: boolean }> {
try {
const response = await authApi.patch("/users/tailscale-settings", {
apiKey,
});
return response.data;
} catch (error) {
handleApiError(error, "update Tailscale settings");
}
}
export async function getTailscaleDevices(): Promise<{
devices: Array<{
id: string;
name: string;
hostname: string;
addresses: string[];
os: string;
lastSeen: string;
}>;
hasApiKey: boolean;
}> {
try {
const response = await authApi.get("/tailscale/devices");
return response.data;
} catch (error) {
handleApiError(error, "fetch Tailscale devices");
}
}
// ============================================================================
// GUACAMOLE SETTINGS
// ============================================================================
+22 -1
View File
@@ -1,7 +1,13 @@
import { AxiosError } from "axios";
import { getAllServerStatuses, handleApiError, sshHostApi } from "@/main-axios";
import {
authApi,
getAllServerStatuses,
handleApiError,
sshHostApi,
} from "@/main-axios";
import type { SSHHost, SSHHostData, ProxyNode } from "@/types/index";
import type { ServerStatus, SSHHostWithStatus } from "@/main-axios";
import type { ProxmoxDiscoverResult } from "@/types/proxmox";
// SSH HOST MANAGEMENT
// ============================================================================
@@ -101,6 +107,21 @@ export async function bulkImportSSHHosts(
}
}
export async function discoverProxmoxGuests(
hostId: number,
): Promise<ProxmoxDiscoverResult> {
try {
const response = await authApi.post(
"/proxmox/discover",
{ hostId },
{ timeout: 120000 },
);
return response.data;
} catch (error) {
handleApiError(error, "discover Proxmox guests");
}
}
export async function bulkUpdateSSHHosts(
hostIds: number[],
updates: Record<string, unknown>,
+76
View File
@@ -0,0 +1,76 @@
import { authApi, handleApiError } from "@/main-axios";
import type { SSOProvider, SSOProviderPublic } from "@/types/index";
export async function getSSOProviders(): Promise<SSOProviderPublic[]> {
try {
const response = await authApi.get("/users/sso-providers");
return response.data;
} catch (error: unknown) {
console.warn("Failed to fetch SSO providers:", error);
return [];
}
}
export async function getAdminSSOProviders(): Promise<SSOProvider[]> {
try {
const response = await authApi.get("/users/sso-providers/admin");
return Array.isArray(response.data) ? response.data : [];
} catch (error) {
handleApiError(error, "fetch admin SSO providers");
return [];
}
}
export async function createSSOProvider(
data: Omit<SSOProvider, "id" | "createdAt" | "updatedAt"> & {
config: Record<string, unknown>;
},
): Promise<SSOProvider> {
try {
const response = await authApi.post("/users/sso-providers", data);
return response.data;
} catch (error) {
handleApiError(error, "create SSO provider");
}
}
export async function updateSSOProvider(
id: number,
data: Partial<Omit<SSOProvider, "id" | "createdAt" | "updatedAt">> & {
config?: Record<string, unknown>;
},
): Promise<SSOProvider> {
try {
const response = await authApi.put(`/users/sso-providers/${id}`, data);
return response.data;
} catch (error) {
handleApiError(error, "update SSO provider");
}
}
export async function deleteSSOProvider(id: number): Promise<void> {
try {
await authApi.delete(`/users/sso-providers/${id}`);
} catch (error) {
handleApiError(error, "delete SSO provider");
}
}
export async function ldapLogin(
providerId: number,
username: string,
password: string,
rememberMe: boolean,
): Promise<{ success: boolean; message: string }> {
try {
const response = await authApi.post("/users/ldap/login", {
providerId,
username,
password,
rememberMe,
});
return response.data;
} catch (error) {
handleApiError(error, "LDAP login");
}
}
+173
View File
@@ -0,0 +1,173 @@
import { tmuxMonitorApi } from "@/main-axios";
export interface TmuxPane {
id: string;
index: number;
pid: number;
active: boolean;
width: number;
height: number;
command: string;
path: string;
title: string;
}
export interface TmuxWindow {
index: number;
name: string;
active: boolean;
panes: TmuxPane[];
}
export interface TmuxSessionOverview {
name: string;
created: number;
lastActivity: number;
attachedClients: number;
windows: TmuxWindow[];
tags: string[];
}
export interface TmuxOverview {
available: boolean;
sessions: TmuxSessionOverview[];
}
export interface TmuxSearchMatch {
paneId: string;
sessionName: string;
windowIndex: number;
line: number;
text: string;
}
export interface TmuxPaneMetrics {
paneId: string;
sessionName: string;
pid: number;
processCount: number;
cpuPercent: number;
memRssKb: number;
gpuMemMb: number;
topCommand: string | null;
}
export async function getTmuxOverview(hostId: number): Promise<TmuxOverview> {
const response = await tmuxMonitorApi.get(`/${hostId}/overview`);
return response.data;
}
/** Select a pane's window+pane on the server so the attached terminal
* (and any other attached client) switches to it. */
export async function focusTmuxPane(
hostId: number,
paneId: string,
): Promise<void> {
await tmuxMonitorApi.post(`/${hostId}/focus`, { paneId });
}
/** Create a new detached session on the host. Starts the tmux server if
* none is running yet. */
export async function createTmuxSession(
hostId: number,
name: string,
): Promise<void> {
await tmuxMonitorApi.post(`/${hostId}/sessions`, { name });
}
/** Create a new window in an existing session. */
export async function createTmuxWindow(
hostId: number,
sessionName: string,
): Promise<void> {
await tmuxMonitorApi.post(`/${hostId}/windows`, { sessionName });
}
/** Rename a session. Saved tags follow the session to its new name. */
export async function renameTmuxSession(
hostId: number,
sessionName: string,
newName: string,
): Promise<void> {
await tmuxMonitorApi.post(`/${hostId}/rename`, { sessionName, newName });
}
/** Kill a session (terminates all of its windows and processes). */
export async function killTmuxSession(
hostId: number,
sessionName: string,
): Promise<void> {
await tmuxMonitorApi.post(`/${hostId}/kill`, { sessionName });
}
/** Kill a window and every pane in it. */
export async function killTmuxWindow(
hostId: number,
sessionName: string,
windowIndex: number,
): Promise<void> {
await tmuxMonitorApi.post(`/${hostId}/kill-window`, {
sessionName,
windowIndex,
});
}
/** Kill a single pane (last pane of a window closes the window). */
export async function killTmuxPane(
hostId: number,
paneId: string,
): Promise<void> {
await tmuxMonitorApi.post(`/${hostId}/kill-pane`, { paneId });
}
/** Split the window containing a pane: "h" adds a pane to the right,
* "v" below (tmux -h/-v semantics). */
export async function splitTmuxPane(
hostId: number,
paneId: string,
direction: "h" | "v",
): Promise<void> {
await tmuxMonitorApi.post(`/${hostId}/split`, { paneId, direction });
}
export interface TmuxSearchResult {
matches: TmuxSearchMatch[];
/** True when a search limit was hit and the results are partial. */
truncated: boolean;
searchedLines: number;
maxPanes: number;
}
export async function searchTmux(
hostId: number,
query: string,
): Promise<TmuxSearchResult> {
const response = await tmuxMonitorApi.get(`/${hostId}/search`, {
params: { q: query },
});
return {
matches: response.data.matches ?? [],
truncated: response.data.truncated ?? false,
searchedLines: response.data.searchedLines ?? 0,
maxPanes: response.data.maxPanes ?? 0,
};
}
export async function getTmuxMetrics(
hostId: number,
): Promise<TmuxPaneMetrics[]> {
const response = await tmuxMonitorApi.get(`/${hostId}/metrics`);
return response.data.panes ?? [];
}
export async function setTmuxSessionTags(
hostId: number,
sessionName: string,
tags: string[],
): Promise<string[]> {
const response = await tmuxMonitorApi.put(`/${hostId}/tags`, {
sessionName,
tags,
});
return response.data.tags ?? [];
}
+24
View File
@@ -231,6 +231,30 @@ export async function updatePasswordResetAllowed(
}
}
export async function getCommandHistoryEnabled(): Promise<{
enabled: boolean;
}> {
try {
const response = await authApi.get("/users/command-history-enabled");
return response.data;
} catch (error) {
handleApiError(error, "get command history enabled");
}
}
export async function updateCommandHistoryEnabled(
enabled: boolean,
): Promise<{ enabled: boolean }> {
try {
const response = await authApi.patch("/users/command-history-enabled", {
enabled,
});
return response.data;
} catch (error) {
handleApiError(error, "update command history enabled");
}
}
export async function updateOIDCConfig(
config: Record<string, unknown>,
): Promise<Record<string, unknown>> {
+250 -95
View File
@@ -12,6 +12,8 @@ import {
ArrowLeft,
Shield,
CheckCircle2,
ChevronDown,
ChevronUp,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import {
@@ -21,7 +23,6 @@ import {
getRegistrationAllowed,
getPasswordLoginAllowed,
getPasswordResetAllowed,
getOIDCConfig,
getSetupRequired,
initiatePasswordReset,
verifyPasswordResetCode,
@@ -34,6 +35,8 @@ import {
getEmbeddedServerStatus,
getCurrentToken,
} from "@/main-axios";
import { getSSOProviders, ldapLogin } from "@/api/sso-provider-api";
import type { SSOProviderPublic } from "@/types/index";
import { ElectronServerConfig as ServerConfigComponent } from "@/auth/ElectronServerConfig";
import { ElectronLoginForm } from "@/auth/ElectronLoginForm";
import { Checkbox } from "@/components/checkbox";
@@ -209,7 +212,9 @@ export function Auth({ onLogin }: AuthProps) {
const { t } = useTranslation();
const [view, setView] = useState<AuthView>("login");
const [loading, setLoading] = useState(false);
const [oidcLoading, setOidcLoading] = useState(false);
const [providerLoading, setProviderLoading] = useState<
Record<number, boolean>
>({});
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
@@ -245,8 +250,11 @@ export function Auth({ onLogin }: AuthProps) {
const [registrationAllowed, setRegistrationAllowed] = useState(true);
const [passwordLoginAllowed, setPasswordLoginAllowed] = useState(true);
const [passwordResetAllowed, setPasswordResetAllowed] = useState(true);
const [oidcConfigured, setOidcConfigured] = useState(false);
const [oidcConfigLoaded, setOidcConfigLoaded] = useState(false);
const [ssoProviders, setSsoProviders] = useState<SSOProviderPublic[]>([]);
const [ssoProvidersLoaded, setSsoProvidersLoaded] = useState(false);
const [expandedLdapId, setExpandedLdapId] = useState<number | null>(null);
const [ldapUsername, setLdapUsername] = useState("");
const [ldapPassword, setLdapPassword] = useState("");
const silentSigninHandledRef = useRef(false);
const [firstUser, setFirstUser] = useState(false);
const [dbConnectionFailed, setDbConnectionFailed] = useState(false);
@@ -276,10 +284,10 @@ export function Auth({ onLogin }: AuthProps) {
getPasswordResetAllowed()
.then((allowed) => setPasswordResetAllowed(allowed))
.catch(() => setPasswordResetAllowed(false));
getOIDCConfig()
.then((res) => setOidcConfigured(!!res))
.catch(() => setOidcConfigured(false))
.finally(() => setOidcConfigLoaded(true));
getSSOProviders()
.then((providers) => setSsoProviders(providers || []))
.catch(() => setSsoProviders([]))
.finally(() => setSsoProvidersLoaded(true));
}, []);
useEffect(() => {
@@ -335,6 +343,18 @@ export function Auth({ onLogin }: AuthProps) {
if (view === "totp" && totpInputRef.current) totpInputRef.current.focus();
}, [view]);
useEffect(() => {
setLdapUsername("");
setLdapPassword("");
}, [expandedLdapId]);
useEffect(() => {
if (!ssoProvidersLoaded) return;
if (!passwordLoginAllowed && ssoProviders.length > 0 && view === "login") {
setView("external");
}
}, [ssoProvidersLoaded, passwordLoginAllowed, ssoProviders.length, view]);
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const success = urlParams.get("success");
@@ -707,65 +727,113 @@ export function Auth({ onLogin }: AuthProps) {
}
}
const handleOIDCLogin = useCallback(async () => {
setOidcLoading(true);
try {
if (isElectron()) {
const electronAPI = (
window as unknown as {
electronAPI?: {
oidcSystemBrowserAuth?: (
authUrl: string,
port: number,
) => Promise<{
success: boolean;
token?: string;
error?: string;
}>;
};
const handleOIDCLogin = useCallback(
async (providerId?: number) => {
const loadingKey = providerId ?? -1;
setProviderLoading((prev) => ({ ...prev, [loadingKey]: true }));
try {
if (isElectron()) {
const electronAPI = (
window as unknown as {
electronAPI?: {
oidcSystemBrowserAuth?: (
authUrl: string,
port: number,
) => Promise<{
success: boolean;
token?: string;
error?: string;
}>;
};
}
).electronAPI;
if (electronAPI?.oidcSystemBrowserAuth) {
const callbackPort = 17832 + Math.floor(Math.random() * 100);
const authResponse = await getOIDCAuthorizeUrl(
rememberMe,
callbackPort,
providerId,
);
const { auth_url: authUrl } = authResponse;
if (!authUrl) throw new Error(t("errors.invalidAuthUrl"));
const result = await electronAPI.oidcSystemBrowserAuth(
authUrl,
callbackPort,
);
if (result.success && result.token) {
localStorage.setItem("jwt", result.token);
window.location.reload();
return;
}
throw new Error(result.error || "Authentication failed");
}
).electronAPI;
if (electronAPI?.oidcSystemBrowserAuth) {
const callbackPort = 17832 + Math.floor(Math.random() * 100);
const authResponse = await getOIDCAuthorizeUrl(
rememberMe,
callbackPort,
);
const { auth_url: authUrl } = authResponse;
if (!authUrl) throw new Error(t("errors.invalidAuthUrl"));
const result = await electronAPI.oidcSystemBrowserAuth(
authUrl,
callbackPort,
);
if (result.success && result.token) {
localStorage.setItem("jwt", result.token);
window.location.reload();
return;
}
throw new Error(result.error || "Authentication failed");
}
const authResponse = await getOIDCAuthorizeUrl(
rememberMe,
undefined,
providerId,
);
const { auth_url: authUrl } = authResponse;
if (!authUrl || authUrl === "undefined")
throw new Error(t("errors.invalidAuthUrl"));
window.location.replace(authUrl);
} catch (err: unknown) {
const error = err as {
message?: string;
response?: { data?: { error?: string } };
};
toast.error(
error?.response?.data?.error ||
error?.message ||
t("errors.failedOidcLogin"),
);
setProviderLoading((prev) => ({ ...prev, [loadingKey]: false }));
}
const authResponse = await getOIDCAuthorizeUrl(rememberMe);
const { auth_url: authUrl } = authResponse;
if (!authUrl || authUrl === "undefined")
throw new Error(t("errors.invalidAuthUrl"));
window.location.replace(authUrl);
} catch (err: unknown) {
const error = err as {
message?: string;
response?: { data?: { error?: string } };
};
toast.error(
error?.response?.data?.error ||
error?.message ||
t("errors.failedOidcLogin"),
);
setOidcLoading(false);
}
}, [rememberMe, t]);
},
[rememberMe, t],
);
const handleLDAPLogin = useCallback(
async (providerId: number) => {
if (!ldapUsername.trim() || !ldapPassword) {
toast.error(t("errors.requiredField"));
return;
}
setProviderLoading((prev) => ({ ...prev, [providerId]: true }));
try {
await ldapLogin(
providerId,
ldapUsername.trim(),
ldapPassword,
rememberMe,
);
const meRes = await getUserInfo();
storeAuth(meRes.username || "");
toast.success(t("messages.loginSuccess"));
onLogin(
meRes.username || "",
meRes.userId || undefined,
!!meRes.is_admin,
);
} catch (err: unknown) {
const error = err as {
response?: { data?: { error?: string } };
message?: string;
};
toast.error(
error?.response?.data?.error ||
error?.message ||
t("auth.ldapLoginFailed"),
);
} finally {
setProviderLoading((prev) => ({ ...prev, [providerId]: false }));
}
},
[ldapUsername, ldapPassword, rememberMe, onLogin, t],
);
useEffect(() => {
if (!oidcConfigLoaded || silentSigninHandledRef.current) return;
if (!ssoProvidersLoaded || silentSigninHandledRef.current) return;
if (!shouldTriggerSilentSignin(window.location.search)) return;
const nextSearch = removeSilentSigninFromSearch(window.location.search);
@@ -776,13 +844,17 @@ export function Auth({ onLogin }: AuthProps) {
);
silentSigninHandledRef.current = true;
if (oidcConfigured && !isElectron()) {
handleOIDCLogin();
const oidcProvider = ssoProviders.find(
(p) => p.type === "oidc" || p.type === "github" || p.type === "google",
);
if (oidcProvider && !isElectron()) {
handleOIDCLogin(oidcProvider.id);
return;
}
toast.info(t("errors.silentSigninOidcUnavailable"));
}, [handleOIDCLogin, oidcConfigLoaded, oidcConfigured, t]);
}, [handleOIDCLogin, ssoProvidersLoaded, ssoProviders, t]);
// Electron server config / webview auth success screens
if (isElectron() && !isInElectronWebView()) {
@@ -908,7 +980,11 @@ export function Auth({ onLogin }: AuthProps) {
label: t("common.register"),
show: (passwordLoginAllowed || firstUser) && registrationAllowed,
},
{ id: "external", label: t("auth.external"), show: oidcConfigured },
{
id: "external",
label: t("auth.external"),
show: ssoProviders.length > 0,
},
];
return (
@@ -1182,36 +1258,115 @@ export function Auth({ onLogin }: AuthProps) {
{view === "external" && (
<div className="flex flex-col gap-4">
{isElectron() ? (
<p className="text-xs text-muted-foreground text-center">
{t("auth.externalNotSupportedInElectron")}
</p>
) : (
<>
<div className="flex items-center gap-2">
<Checkbox
id="rememberOIDC"
checked={rememberMe}
onCheckedChange={(v) => setRememberMe(v === true)}
/>
<label
htmlFor="rememberOIDC"
className="text-xs text-muted-foreground cursor-pointer"
<div className="flex items-center gap-2">
<Checkbox
id="rememberSSO"
checked={rememberMe}
onCheckedChange={(v) => setRememberMe(v === true)}
/>
<label
htmlFor="rememberSSO"
className="text-xs text-muted-foreground cursor-pointer"
>
{t("auth.rememberMe")}
</label>
</div>
<div className="flex flex-col gap-3">
{ssoProviders.map((provider) => {
const isLoading = !!providerLoading[provider.id];
if (provider.type === "ldap") {
const isExpanded = expandedLdapId === provider.id;
return (
<div
key={provider.id}
className="flex flex-col gap-0 border border-border"
>
<button
type="button"
onClick={() =>
setExpandedLdapId(
isExpanded ? null : provider.id,
)
}
className="flex items-center justify-between w-full px-3 py-2.5 text-xs font-bold uppercase tracking-widest text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<span>
{t("auth.loginWithProvider", {
name: provider.name,
})}
</span>
{isExpanded ? (
<ChevronUp className="size-3.5" />
) : (
<ChevronDown className="size-3.5" />
)}
</button>
{isExpanded && (
<form
onSubmit={(e) => {
e.preventDefault();
handleLDAPLogin(provider.id);
}}
className="flex flex-col gap-3 p-3 border-t border-border"
>
<Field
label={t("auth.ldapUsername")}
htmlFor={`ldap-user-${provider.id}`}
>
<Input
id={`ldap-user-${provider.id}`}
value={ldapUsername}
onChange={(e) =>
setLdapUsername(e.target.value)
}
disabled={isLoading}
autoFocus
/>
</Field>
<Field
label={t("auth.ldapPassword")}
htmlFor={`ldap-pass-${provider.id}`}
>
<PasswordInput
id={`ldap-pass-${provider.id}`}
value={ldapPassword}
onChange={setLdapPassword}
disabled={isLoading}
/>
</Field>
<Button
type="submit"
className="w-full bg-accent-brand hover:bg-accent-brand/90 text-background font-bold"
disabled={isLoading}
>
{isLoading
? t("common.loading")
: t("auth.ldapSignIn")}
</Button>
</form>
)}
</div>
);
}
return (
<Button
key={provider.id}
onClick={() => handleOIDCLogin(provider.id)}
disabled={isLoading}
className="w-full bg-accent-brand hover:bg-accent-brand/90 text-background font-bold"
>
{t("auth.rememberMe")}
</label>
</div>
<Button
onClick={handleOIDCLogin}
disabled={oidcLoading}
className="w-full bg-accent-brand hover:bg-accent-brand/90 text-background font-bold"
>
{oidcLoading
? t("common.loading")
: t("auth.loginWithExternal")}
</Button>
</>
)}
{isLoading
? t("common.loading")
: t("auth.loginWithProvider", {
name: provider.name,
})}
</Button>
);
})}
</div>
</div>
)}
+362 -291
View File
@@ -18,7 +18,6 @@ import {
getUserInfo,
getRegistrationAllowed,
getPasswordLoginAllowed,
getOIDCConfig,
getSetupRequired,
initiatePasswordReset,
verifyPasswordResetCode,
@@ -31,6 +30,8 @@ import {
getEmbeddedServerStatus,
getCurrentToken,
} from "@/main-axios";
import { getSSOProviders, ldapLogin } from "@/api/sso-provider-api";
import type { SSOProviderPublic } from "@/types/index";
import { ElectronServerConfig as ServerConfigComponent } from "@/auth/ElectronServerConfig.tsx";
import { ElectronLoginForm } from "@/auth/ElectronLoginForm.tsx";
import {
@@ -102,9 +103,7 @@ export function Auth({
return false;
}, []);
const [tab, setTab] = useState<"login" | "signup" | "external" | "reset">(
"login",
);
const [tab, setTab] = useState<"login" | "signup" | "reset">("login");
const [localUsername, setLocalUsername] = useState("");
const [password, setPassword] = useState("");
const [signupConfirmPassword, setSignupConfirmPassword] = useState("");
@@ -123,8 +122,12 @@ export function Auth({
const [firstUserToastShown, setFirstUserToastShown] = useState(false);
const [registrationAllowed, setRegistrationAllowed] = useState(true);
const [passwordLoginAllowed, setPasswordLoginAllowed] = useState(true);
const [oidcConfigured, setOidcConfigured] = useState(false);
const [oidcConfigLoaded, setOidcConfigLoaded] = useState(false);
const [ssoProviders, setSsoProviders] = useState<SSOProviderPublic[]>([]);
const [ssoProvidersLoaded, setSsoProvidersLoaded] = useState(false);
const [ldapProviderId, setLdapProviderId] = useState<number | null>(null);
const [ldapUsername, setLdapUsername] = useState("");
const [ldapPassword, setLdapPassword] = useState("");
const [ldapLoading, setLdapLoading] = useState(false);
const silentSigninHandledRef = useRef(false);
const [resetStep, setResetStep] = useState<
@@ -243,23 +246,15 @@ export function Auth({
}, []);
useEffect(() => {
getOIDCConfig()
.then((response) => {
if (response) {
setOidcConfigured(true);
} else {
setOidcConfigured(false);
}
getSSOProviders()
.then((providers) => {
setSsoProviders(providers || []);
})
.catch((error) => {
if (error.response?.status === 404) {
setOidcConfigured(false);
} else {
setOidcConfigured(false);
}
.catch(() => {
setSsoProviders([]);
})
.finally(() => {
setOidcConfigLoaded(true);
setSsoProvidersLoaded(true);
});
}, []);
@@ -292,11 +287,8 @@ export function Auth({
});
}, [setDbError, firstUserToastShown, showServerConfig, t]);
useEffect(() => {
if (!passwordLoginAllowed && oidcConfigured && tab !== "external") {
setTab("external");
}
}, [passwordLoginAllowed, oidcConfigured, tab]);
// When password login is disabled and SSO is available, stay on login tab
// (SSO buttons appear below the form regardless of tab)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
@@ -650,33 +642,68 @@ export function Auth({
}
}
const handleOIDCLogin = useCallback(async () => {
setOidcLoading(true);
try {
const authResponse = await getOIDCAuthorizeUrl(rememberMe);
const { auth_url: authUrl } = authResponse;
const handleOIDCLogin = useCallback(
async (providerId?: number) => {
setOidcLoading(true);
try {
const authResponse = await getOIDCAuthorizeUrl(
rememberMe,
undefined,
providerId,
);
const { auth_url: authUrl } = authResponse;
if (!authUrl || authUrl === "undefined") {
throw new Error(t("errors.invalidAuthUrl"));
if (!authUrl || authUrl === "undefined") {
throw new Error(t("errors.invalidAuthUrl"));
}
window.location.replace(authUrl);
} catch (err: unknown) {
const error = err as {
message?: string;
response?: { data?: { error?: string } };
};
const errorMessage =
error?.response?.data?.error ||
error?.message ||
t("errors.failedOidcLogin");
toast.error(errorMessage);
setOidcLoading(false);
}
},
[rememberMe, t],
);
window.location.replace(authUrl);
} catch (err: unknown) {
const error = err as {
message?: string;
response?: { data?: { error?: string } };
};
const errorMessage =
error?.response?.data?.error ||
error?.message ||
t("errors.failedOidcLogin");
toast.error(errorMessage);
setOidcLoading(false);
}
}, [rememberMe, t]);
const handleLDAPLogin = useCallback(
async (providerId: number) => {
if (!ldapUsername.trim() || !ldapPassword) {
toast.error(t("errors.requiredField"));
return;
}
setLdapLoading(true);
try {
await ldapLogin(providerId, ldapUsername, ldapPassword, rememberMe);
const userInfo = await getUserInfo();
onLogin(userInfo);
} catch (err: unknown) {
const error = err as {
response?: { data?: { error?: string } };
message?: string;
};
toast.error(
error?.response?.data?.error ||
error?.message ||
t("auth.ldapLoginFailed"),
);
} finally {
setLdapLoading(false);
}
},
[ldapUsername, ldapPassword, rememberMe, onLogin, t],
);
useEffect(() => {
if (!oidcConfigLoaded || silentSigninHandledRef.current) return;
if (!ssoProvidersLoaded || silentSigninHandledRef.current) return;
if (!shouldTriggerSilentSignin(window.location.search)) return;
const nextSearch = removeSilentSigninFromSearch(window.location.search);
@@ -687,13 +714,22 @@ export function Auth({
);
silentSigninHandledRef.current = true;
if (oidcConfigured && !isElectron()) {
handleOIDCLogin();
const oidcProvider = ssoProviders.find(
(p) => p.type === "oidc" || p.type === "github" || p.type === "google",
);
if (oidcProvider && !isElectron()) {
handleOIDCLogin(oidcProvider.id);
return;
}
if (ssoProviders.length > 0 && !isElectron()) {
const first = ssoProviders[0];
if (first.type !== "ldap") handleOIDCLogin(first.id);
return;
}
toast.info(t("errors.silentSigninOidcUnavailable"));
}, [handleOIDCLogin, oidcConfigLoaded, oidcConfigured, t]);
}, [handleOIDCLogin, ssoProvidersLoaded, ssoProviders, t]);
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
@@ -1233,8 +1269,8 @@ export function Auth({
const hasSignup =
(passwordLoginAllowed || firstUser) &&
registrationAllowed;
const hasOIDC = oidcConfigured;
const hasAnyAuth = hasLogin || hasSignup || hasOIDC;
const hasSso = ssoProviders.length > 0;
const hasAnyAuth = hasLogin || hasSignup || hasSso;
if (!hasAnyAuth) {
return (
@@ -1253,12 +1289,8 @@ export function Auth({
<>
<Tabs
value={tab}
onValueChange={(value) => {
const newTab = value as
| "login"
| "signup"
| "external"
| "reset";
onValueChange={(v) => {
const newTab = v as "login" | "signup" | "reset";
setTab(newTab);
if (tab === "reset") resetPasswordState();
if (
@@ -1290,15 +1322,6 @@ export function Auth({
{t("common.register")}
</TabsTrigger>
)}
{oidcConfigured && (
<TabsTrigger
value="external"
disabled={oidcLoading}
className="flex-1"
>
{t("auth.external")}
</TabsTrigger>
)}
</TabsList>
</Tabs>
@@ -1308,241 +1331,182 @@ export function Auth({
? t("auth.loginTitle")
: tab === "signup"
? t("auth.registerTitle")
: tab === "external"
? t("auth.loginWithExternal")
: t("auth.forgotPassword")}
: t("auth.forgotPassword")}
</h2>
</div>
{tab === "external" || tab === "reset" ? (
{tab === "reset" ? (
<div className="flex flex-col gap-5">
{tab === "external" && (
{resetStep === "initiate" && (
<>
<Alert variant="destructive" className="mb-4">
<AlertTitle>{t("common.warning")}</AlertTitle>
<AlertDescription>
{t("auth.dataLossWarning")}
</AlertDescription>
</Alert>
<div className="text-center text-muted-foreground mb-4">
<p>{t("auth.loginWithExternalDesc")}</p>
<p>{t("auth.resetCodeDesc")}</p>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="reset-username">
{t("common.username")}
</Label>
<Input
id="reset-username"
type="text"
required
className="h-11 text-base"
value={localUsername}
onChange={(e) =>
setLocalUsername(e.target.value)
}
disabled={resetLoading}
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={
resetLoading || !localUsername.trim()
}
onClick={handleInitiatePasswordReset}
>
{resetLoading
? Spinner
: t("auth.sendResetCode")}
</Button>
</div>
{(() => {
if (isElectron()) {
return (
<div className="text-center p-4 bg-muted/50 rounded-lg border">
<p className="text-muted-foreground text-sm">
{t(
"auth.externalNotSupportedInElectron",
)}
</p>
</div>
);
} else {
return (
<>
<div className="flex items-center gap-2">
<Checkbox
id="rememberMeOIDC"
checked={rememberMe}
onCheckedChange={(checked) =>
setRememberMe(checked === true)
}
/>
<Label htmlFor="rememberMeOIDC">
{t("auth.rememberMe")}
</Label>
</div>
<Button
type="button"
className="w-full h-11 mt-2 text-base font-semibold"
disabled={oidcLoading}
onClick={handleOIDCLogin}
>
{oidcLoading
? Spinner
: t("auth.loginWithExternal")}
</Button>
</>
);
}
})()}
</>
)}
{tab === "reset" && (
{resetStep === "verify" && (
<>
{resetStep === "initiate" && (
<>
<Alert
variant="destructive"
className="mb-4"
>
<AlertTitle>
{t("common.warning")}
</AlertTitle>
<AlertDescription>
{t("auth.dataLossWarning")}
</AlertDescription>
</Alert>
<div className="text-center text-muted-foreground mb-4">
<p>{t("auth.resetCodeDesc")}</p>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="reset-username">
{t("common.username")}
</Label>
<Input
id="reset-username"
type="text"
required
className="h-11 text-base"
value={localUsername}
onChange={(e) =>
setLocalUsername(e.target.value)
}
disabled={resetLoading}
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={
resetLoading || !localUsername.trim()
}
onClick={handleInitiatePasswordReset}
>
{resetLoading
? Spinner
: t("auth.sendResetCode")}
</Button>
</div>
</>
)}
<div className="text-center text-muted-foreground mb-4">
<p>
{t("auth.enterResetCode")}{" "}
<strong>{localUsername}</strong>
</p>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="reset-code">
{t("auth.resetCode")}
</Label>
<Input
id="reset-code"
type="text"
required
maxLength={6}
className="h-11 text-base text-center text-lg tracking-widest"
value={resetCode}
onChange={(e) =>
setResetCode(
e.target.value.replace(/\D/g, ""),
)
}
disabled={resetLoading}
placeholder="000000"
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={
resetLoading || resetCode.length !== 6
}
onClick={handleVerifyResetCode}
>
{resetLoading
? Spinner
: t("auth.verifyCodeButton")}
</Button>
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading}
onClick={() => {
setResetStep("initiate");
setResetCode("");
}}
>
{t("common.back")}
</Button>
</div>
</>
)}
{resetStep === "verify" && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>
{t("auth.enterResetCode")}{" "}
<strong>{localUsername}</strong>
</p>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="reset-code">
{t("auth.resetCode")}
</Label>
<Input
id="reset-code"
type="text"
required
maxLength={6}
className="h-11 text-base text-center text-lg tracking-widest"
value={resetCode}
onChange={(e) =>
setResetCode(
e.target.value.replace(/\D/g, ""),
)
}
disabled={resetLoading}
placeholder="000000"
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={
resetLoading || resetCode.length !== 6
}
onClick={handleVerifyResetCode}
>
{resetLoading
? Spinner
: t("auth.verifyCodeButton")}
</Button>
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading}
onClick={() => {
setResetStep("initiate");
setResetCode("");
}}
>
{t("common.back")}
</Button>
</div>
</>
)}
{resetStep === "newPassword" &&
!resetSuccess && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>
{t("auth.enterNewPassword")}{" "}
<strong>{localUsername}</strong>
</p>
</div>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-2">
<Label htmlFor="new-p assword">
{t("auth.newPassword")}
</Label>
<PasswordInput
id="new-password"
required
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
value={newPassword}
onChange={(e) =>
setNewPassword(e.target.value)
}
disabled={resetLoading}
autoComplete="new-password"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="confirm-password">
{t("auth.confirmNewPassword")}
</Label>
<PasswordInput
id="confirm-password"
required
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
value={confirmPassword}
onChange={(e) =>
setConfirmPassword(e.target.value)
}
disabled={resetLoading}
autoComplete="new-password"
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={
resetLoading ||
!newPassword ||
!confirmPassword
}
onClick={handleCompletePasswordReset}
>
{resetLoading
? Spinner
: t("auth.resetPasswordButton")}
</Button>
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading}
onClick={() => {
setResetStep("verify");
setNewPassword("");
setConfirmPassword("");
}}
>
{t("common.back")}
</Button>
</div>
</>
)}
{resetStep === "newPassword" && !resetSuccess && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>
{t("auth.enterNewPassword")}{" "}
<strong>{localUsername}</strong>
</p>
</div>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-2">
<Label htmlFor="new-p assword">
{t("auth.newPassword")}
</Label>
<PasswordInput
id="new-password"
required
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
value={newPassword}
onChange={(e) =>
setNewPassword(e.target.value)
}
disabled={resetLoading}
autoComplete="new-password"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="confirm-password">
{t("auth.confirmNewPassword")}
</Label>
<PasswordInput
id="confirm-password"
required
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
value={confirmPassword}
onChange={(e) =>
setConfirmPassword(e.target.value)
}
disabled={resetLoading}
autoComplete="new-password"
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={
resetLoading ||
!newPassword ||
!confirmPassword
}
onClick={handleCompletePasswordReset}
>
{resetLoading
? Spinner
: t("auth.resetPasswordButton")}
</Button>
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading}
onClick={() => {
setResetStep("verify");
setNewPassword("");
setConfirmPassword("");
}}
>
{t("common.back")}
</Button>
</div>
</>
)}
</div>
@@ -1641,6 +1605,113 @@ export function Auth({
{t("auth.resetPasswordButton")}
</Button>
)}
{ssoProviders.length > 0 && !isElectron() && (
<div className="flex flex-col gap-3 pt-2">
<div className="flex items-center gap-3">
<div className="flex-1 border-t border-border" />
<span className="text-xs text-muted-foreground px-1 whitespace-nowrap">
{t("auth.orContinueWith")}
</span>
<div className="flex-1 border-t border-border" />
</div>
{ssoProviders.map((provider) => {
if (provider.type === "ldap") {
const isExpanded =
ldapProviderId === provider.id;
return (
<div
key={provider.id}
className="flex flex-col gap-2"
>
<Button
type="button"
variant="outline"
className="w-full h-10 text-sm font-medium"
onClick={() =>
setLdapProviderId(
isExpanded ? null : provider.id,
)
}
>
{t("auth.loginWithProvider", {
name: provider.name,
})}
</Button>
{isExpanded && (
<div className="flex flex-col gap-2 p-3 border border-border rounded-md bg-muted/30">
<Input
type="text"
placeholder={t(
"auth.ldapUsername",
)}
value={ldapUsername}
onChange={(e) =>
setLdapUsername(e.target.value)
}
className="h-9 text-sm"
disabled={ldapLoading}
/>
<Input
type="password"
placeholder={t(
"auth.ldapPassword",
)}
value={ldapPassword}
onChange={(e) =>
setLdapPassword(e.target.value)
}
className="h-9 text-sm"
disabled={ldapLoading}
onKeyDown={(e) => {
if (e.key === "Enter")
handleLDAPLogin(provider.id);
}}
/>
<Button
type="button"
className="w-full h-9 text-sm font-medium"
disabled={
ldapLoading ||
!ldapUsername.trim() ||
!ldapPassword
}
onClick={() =>
handleLDAPLogin(provider.id)
}
>
{ldapLoading
? Spinner
: t("auth.loginWithProvider", {
name: provider.name,
})}
</Button>
</div>
)}
</div>
);
}
return (
<Button
key={provider.id}
type="button"
variant="outline"
className="w-full h-10 text-sm font-medium"
disabled={oidcLoading}
onClick={() =>
handleOIDCLogin(provider.id)
}
>
{oidcLoading
? Spinner
: t("auth.loginWithProvider", {
name: provider.name,
})}
</Button>
);
})}
</div>
)}
</form>
)}
+37
View File
@@ -0,0 +1,37 @@
import React, { useState } from "react";
import { Auth } from "@/auth/LoginPage.tsx";
interface LoginScreenProps {
authLoading: boolean;
onAuthSuccess: (authData: {
isAdmin: boolean;
username: string | null;
userId: string | null;
}) => void;
}
export function LoginScreen({
authLoading,
onAuthSuccess,
}: LoginScreenProps): React.ReactElement {
const [loggedIn, setLoggedIn] = useState(false);
const [, setIsAdmin] = useState(false);
const [, setUsername] = useState<string | null>(null);
const [, setUserId] = useState<string | null>(null);
const [, setDbError] = useState<string | null>(null);
return (
<div className="w-full h-full flex items-center justify-center">
<Auth
setLoggedIn={setLoggedIn}
setIsAdmin={setIsAdmin}
setUsername={setUsername}
setUserId={setUserId}
loggedIn={loggedIn}
authLoading={authLoading}
setDbError={setDbError}
onAuthSuccess={onAuthSuccess}
/>
</div>
);
}
@@ -0,0 +1,503 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type ReactNode,
type DragEvent,
type MouseEvent as ReactMouseEvent,
} from "react";
import { useTranslation } from "react-i18next";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import {
GripVertical,
Trash2,
Plus,
Columns2,
StretchHorizontal,
} from "lucide-react";
import type { GridSlot, GridCardCatalogEntry } from "./types";
import {
MAX_COLUMNS,
MIN_COLUMNS,
clampColumns,
nextColSpan,
sortSlots,
reorderSlots,
addSlot,
removeSlot,
setColSpan,
setHeight,
heightToRowSpan,
} from "./layout-utils";
type DragState = { id: string; order: number } | null;
/**
* Masonry is implemented with the CSS-grid row-span trick: the grid has many
* tiny rows (`ROW_UNIT`px each) and every tile spans `ceil(height / ROW_UNIT)`
* rows. With `grid-auto-flow: row dense` the browser packs short tiles under
* tall ones (tetris) while still honouring multi-column spans natively.
*/
const ROW_UNIT = 8;
const ROW_GAP = 12;
function DropZone({
order,
active,
onDropAt,
onDragOver,
}: {
order: number;
active: boolean;
onDropAt: (order: number) => void;
onDragOver: (e: DragEvent) => void;
}) {
const [over, setOver] = useState(false);
if (!active) return null;
return (
<div
className={cn(
"transition-all duration-150",
over
? "h-16 border-2 border-dashed border-accent-brand/60 bg-accent-brand/5"
: "h-3",
)}
onDragOver={(e) => {
onDragOver(e);
setOver(true);
}}
onDragLeave={() => setOver(false)}
onDrop={() => {
setOver(false);
onDropAt(order);
}}
/>
);
}
function TileChrome({
columns,
label,
onRemove,
onCycleWidth,
onResizeStart,
}: {
columns: number;
label: string;
onRemove: () => void;
onCycleWidth: () => void;
onResizeStart: (e: ReactMouseEvent) => void;
}) {
const { t } = useTranslation();
return (
<>
<div className="pointer-events-none absolute inset-0 z-10 border-2 border-dashed border-accent-brand/30" />
<div className="absolute top-2 right-2 z-20 flex items-center gap-1">
<div
className="flex size-6 cursor-grab items-center justify-center border border-border bg-card active:cursor-grabbing"
title={t("cardGrid.dragToMove")}
>
<GripVertical className="size-3 text-muted-foreground" />
</div>
{columns > 1 && (
<button
onClick={onCycleWidth}
className="flex size-6 items-center justify-center border border-border bg-card transition-colors hover:border-accent-brand/40 hover:bg-accent-brand/10"
title={t("cardGrid.changeWidth")}
>
<Columns2 className="size-3 text-muted-foreground" />
</button>
)}
<button
onClick={onRemove}
className="flex size-6 items-center justify-center border border-border bg-card transition-colors hover:border-destructive/40 hover:bg-destructive/10"
title={t("cardGrid.removeCard", { label })}
>
<Trash2 className="size-3 text-muted-foreground" />
</button>
</div>
<div
onMouseDown={onResizeStart}
className="group/resize absolute bottom-0 left-0 right-0 z-20 flex h-2.5 cursor-row-resize items-center justify-center"
title={t("cardGrid.dragToResize")}
>
<div className="h-0.5 w-12 rounded-full bg-border transition-colors group-hover/resize:bg-accent-brand/60" />
</div>
</>
);
}
function AddTray({
catalog,
presentIds,
onAdd,
}: {
catalog: GridCardCatalogEntry[];
presentIds: Set<string>;
onAdd: (entry: GridCardCatalogEntry) => void;
}) {
const { t } = useTranslation();
const available = catalog.filter((c) => !presentIds.has(c.id));
if (available.length === 0) return null;
return (
<div className="mt-3 flex flex-wrap items-center gap-2">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("cardGrid.addCard")}
</span>
{available.map((card) => (
<button
key={card.id}
onClick={() => onAdd(card)}
title={card.description}
className="flex items-center gap-1.5 border border-dashed border-border px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:border-accent-brand/60 hover:bg-accent-brand/5 hover:text-foreground"
>
<Plus className="size-3 text-accent-brand" />
{card.label}
</button>
))}
</div>
);
}
/**
* One masonry tile. Measures its natural (or overridden) height and reports the
* number of grid rows it should span so the parent grid can pack densely.
*/
function MasonryTile({
slot,
span,
rows,
editMode,
isDragging,
effectiveColumns,
label,
measureRef,
children,
onReportRows,
onDragStart,
onDragEnd,
onDragOver,
onDrop,
onRemove,
onCycleWidth,
onResizeStart,
}: {
slot: GridSlot;
span: number;
rows: number;
editMode: boolean;
isDragging: boolean;
effectiveColumns: number;
label: string;
measureRef: (el: HTMLDivElement | null) => void;
children: ReactNode;
onReportRows: (rows: number) => void;
onDragStart: () => void;
onDragEnd: () => void;
onDragOver: (e: DragEvent) => void;
onDrop: () => void;
onRemove: () => void;
onCycleWidth: () => void;
onResizeStart: (e: ReactMouseEvent) => void;
}) {
const innerRef = useRef<HTMLDivElement | null>(null);
const fixedHeight = slot.height ?? undefined;
// Report the span (in row units) from the measured content height. When an
// explicit height override is set we derive rows from it directly.
useLayoutEffect(() => {
const el = innerRef.current;
if (!el) return;
const report = () => {
const h = fixedHeight ?? el.getBoundingClientRect().height;
onReportRows(heightToRowSpan(h, ROW_UNIT, ROW_GAP));
};
report();
if (fixedHeight != null) return;
const ro = new ResizeObserver(report);
ro.observe(el);
return () => ro.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fixedHeight, span, effectiveColumns]);
return (
<div
ref={measureRef}
draggable={editMode}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragOver={onDragOver}
onDrop={onDrop}
className={cn(
"relative flex min-w-0 flex-col transition-opacity",
editMode && "select-none",
isDragging ? "opacity-40" : "opacity-100",
)}
style={{
gridColumn: `span ${span} / span ${span}`,
gridRow: `span ${rows} / span ${rows}`,
}}
>
<div
ref={innerRef}
className={cn(
"min-h-0",
fixedHeight != null && "overflow-hidden [&>*]:h-full",
)}
style={fixedHeight != null ? { height: fixedHeight } : undefined}
>
{children}
</div>
{editMode && (
<TileChrome
columns={effectiveColumns}
label={label}
onRemove={onRemove}
onCycleWidth={onCycleWidth}
onResizeStart={onResizeStart}
/>
)}
</div>
);
}
interface CardGridCanvasProps {
slots: GridSlot[];
columns: number;
editMode: boolean;
/** Render the card body for a given slot id. */
renderCard: (id: string) => ReactNode;
/** Cards available to add in edit mode (already-present ones are filtered out). */
cardCatalog: GridCardCatalogEntry[];
onChange: (slots: GridSlot[], columns: number) => void;
/** Human label lookup for a card id (used by remove/add chrome). */
cardLabel?: (id: string) => string;
className?: string;
}
export function CardGridCanvas({
slots,
columns,
editMode,
renderCard,
cardCatalog,
onChange,
cardLabel,
className,
}: CardGridCanvasProps) {
const { t } = useTranslation();
const isMobile = useIsMobile();
const [dragState, setDragState] = useState<DragState>(null);
const [rowSpans, setRowSpans] = useState<Record<string, number>>({});
const tileRefs = useRef<Map<string, HTMLDivElement | null>>(new Map());
const sorted = sortSlots(slots);
const presentIds = new Set(slots.map((s) => s.id));
const labelFor = useCallback(
(id: string) => cardLabel?.(id) ?? id,
[cardLabel],
);
// Drop row-span entries for cards that are no longer present.
useEffect(() => {
setRowSpans((prev) => {
const next: Record<string, number> = {};
let changed = false;
for (const s of slots) {
if (prev[s.id] != null) next[s.id] = prev[s.id];
else changed = true;
}
if (Object.keys(next).length !== Object.keys(prev).length) changed = true;
return changed ? next : prev;
});
}, [slots]);
const reportRows = useCallback((id: string, rows: number) => {
setRowSpans((prev) => (prev[id] === rows ? prev : { ...prev, [id]: rows }));
}, []);
const commitSlots = useCallback(
(next: GridSlot[]) => onChange(next, columns),
[onChange, columns],
);
const handleDropAt = useCallback(
(targetOrder: number) => {
if (!dragState) return;
commitSlots(reorderSlots(slots, dragState.id, targetOrder));
setDragState(null);
},
[dragState, slots, commitSlots],
);
const handleRemove = useCallback(
(id: string) => commitSlots(removeSlot(slots, id)),
[slots, commitSlots],
);
const handleAdd = useCallback(
(entry: GridCardCatalogEntry) =>
commitSlots(addSlot(slots, entry, columns)),
[slots, columns, commitSlots],
);
const handleCycleWidth = useCallback(
(id: string) => {
const slot = slots.find((s) => s.id === id);
if (!slot) return;
commitSlots(setColSpan(slots, id, nextColSpan(slot.colSpan, columns)));
},
[slots, columns, commitSlots],
);
const handleResizeStart = useCallback(
(id: string, e: ReactMouseEvent) => {
e.preventDefault();
e.stopPropagation();
const startY = e.clientY;
const startH =
tileRefs.current.get(id)?.getBoundingClientRect().height ?? 120;
const onMove = (ev: globalThis.MouseEvent) => {
onChange(setHeight(slots, id, startH + (ev.clientY - startY)), columns);
};
const onUp = () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
},
[slots, columns, onChange],
);
const handleDragOver = (e: DragEvent) => e.preventDefault();
// Mobile: simple single-column stack, no DnD / resize / edit chrome.
if (isMobile) {
return (
<div className={cn("flex flex-col gap-3", className)}>
{sorted.map((slot) => (
<div key={slot.id} className="w-full">
{renderCard(slot.id)}
</div>
))}
</div>
);
}
const effectiveColumns = clampColumns(columns);
return (
<div className={cn("flex flex-col", className)}>
<div
className="grid items-start"
style={{
gridTemplateColumns: `repeat(${effectiveColumns}, minmax(0, 1fr))`,
gridAutoRows: `${ROW_UNIT}px`,
gridAutoFlow: "row dense",
columnGap: `${ROW_GAP}px`,
rowGap: `${ROW_GAP}px`,
}}
>
{sorted.map((slot) => {
const span = Math.min(slot.colSpan, effectiveColumns);
const isDragging = dragState?.id === slot.id;
const rows = rowSpans[slot.id] ?? 24;
return (
<MasonryTile
key={slot.id}
slot={slot}
span={span}
rows={rows}
editMode={editMode}
isDragging={isDragging}
effectiveColumns={effectiveColumns}
label={labelFor(slot.id)}
measureRef={(el) => {
tileRefs.current.set(slot.id, el);
}}
onReportRows={(r) => reportRows(slot.id, r)}
onDragStart={() =>
setDragState({ id: slot.id, order: slot.order })
}
onDragEnd={() => setDragState(null)}
onDragOver={handleDragOver}
onDrop={() => handleDropAt(slot.order)}
onRemove={() => handleRemove(slot.id)}
onCycleWidth={() => handleCycleWidth(slot.id)}
onResizeStart={(e) => handleResizeStart(slot.id, e)}
>
{renderCard(slot.id)}
</MasonryTile>
);
})}
</div>
{editMode && (
<>
{sorted.length === 0 && (
<div className="flex items-center justify-center border border-dashed border-border/40 py-10 text-xs text-muted-foreground/40">
{t("cardGrid.empty")}
</div>
)}
<AddTray
catalog={cardCatalog}
presentIds={presentIds}
onAdd={handleAdd}
/>
</>
)}
{/* Drop target covering the end of the grid while dragging. */}
{editMode && (
<DropZone
order={sorted.length}
active={!!dragState}
onDropAt={handleDropAt}
onDragOver={handleDragOver}
/>
)}
</div>
);
}
/** Small stepper to change the masonry column count, for an edit toolbar. */
export function ColumnCountStepper({
columns,
onChange,
}: {
columns: number;
onChange: (columns: number) => void;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-1.5">
<StretchHorizontal className="size-3.5 text-muted-foreground" />
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("cardGrid.columns")}
</span>
<div className="flex items-center border border-border">
<button
className="flex size-6 items-center justify-center text-muted-foreground transition-colors hover:bg-muted disabled:opacity-30"
disabled={columns <= MIN_COLUMNS}
onClick={() => onChange(clampColumns(columns - 1))}
>
</button>
<span className="w-6 text-center text-xs font-bold tabular-nums">
{columns}
</span>
<button
className="flex size-6 items-center justify-center text-muted-foreground transition-colors hover:bg-muted disabled:opacity-30"
disabled={columns >= MAX_COLUMNS}
onClick={() => onChange(clampColumns(columns + 1))}
>
+
</button>
</div>
</div>
);
}
@@ -0,0 +1,131 @@
import { describe, it, expect } from "vitest";
import {
clampColumns,
nextColSpan,
sortSlots,
reorderSlots,
addSlot,
removeSlot,
setColSpan,
setHeight,
heightToRowSpan,
MIN_TILE_HEIGHT,
} from "./layout-utils";
import type { GridSlot } from "./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);
});
});
+105
View File
@@ -0,0 +1,105 @@
import type { GridSlot, GridColSpan, GridCardCatalogEntry } from "./types";
export const MIN_TILE_HEIGHT = 80;
export const MAX_COLUMNS = 4;
export const MIN_COLUMNS = 1;
export function clampColumns(n: number): number {
if (!Number.isFinite(n)) return MIN_COLUMNS;
return Math.min(MAX_COLUMNS, Math.max(MIN_COLUMNS, Math.round(n)));
}
/** Cycle a tile width 1 -> 2 -> 3 -> 1, capped at the available column count. */
export function nextColSpan(
current: GridColSpan,
columns: number,
): GridColSpan {
const max = Math.min(3, Math.max(1, columns)) as GridColSpan;
return (((current % max) + 1) as GridColSpan) || 1;
}
/** Sort slots by order ascending (stable copy). */
export function sortSlots(slots: GridSlot[]): GridSlot[] {
return [...slots].sort((a, b) => a.order - b.order);
}
/**
* Move the dragged slot so it lands at `targetOrder`, then renumber every slot's
* order to a dense 0..n-1 sequence (the masonry placement is derived from order).
*/
export function reorderSlots(
slots: GridSlot[],
draggedId: string,
targetOrder: number,
): GridSlot[] {
const moved = slots.find((s) => s.id === draggedId);
if (!moved) return sortSlots(slots).map((s, i) => ({ ...s, order: i }));
const without = sortSlots(slots).filter((s) => s.id !== draggedId);
const insertIdx = without.findIndex((s) => s.order >= targetOrder);
const at = insertIdx === -1 ? without.length : insertIdx;
return [...without.slice(0, at), moved, ...without.slice(at)].map((s, i) => ({
...s,
order: i,
}));
}
/** Append a catalog card as a new slot, clamping its width to the column count. */
export function addSlot(
slots: GridSlot[],
entry: GridCardCatalogEntry,
columns: number,
): GridSlot[] {
if (slots.some((s) => s.id === entry.id)) return slots;
const maxOrder = slots.reduce((m, s) => Math.max(m, s.order), -1);
const colSpan = Math.min(
entry.defaultColSpan ?? 1,
Math.min(3, Math.max(1, columns)),
) as GridColSpan;
return [
...slots,
{
id: entry.id,
order: maxOrder + 1,
colSpan,
height: entry.defaultHeight ?? null,
},
];
}
export function removeSlot(slots: GridSlot[], id: string): GridSlot[] {
return slots
.filter((s) => s.id !== id)
.sort((a, b) => a.order - b.order)
.map((s, i) => ({ ...s, order: i }));
}
export function setColSpan(
slots: GridSlot[],
id: string,
colSpan: GridColSpan,
): GridSlot[] {
return slots.map((s) => (s.id === id ? { ...s, colSpan } : s));
}
export function setHeight(
slots: GridSlot[],
id: string,
height: number,
): GridSlot[] {
const h = Math.max(MIN_TILE_HEIGHT, Math.round(height));
return slots.map((s) => (s.id === id ? { ...s, height: h } : s));
}
/**
* Convert a tile pixel height into the number of grid rows it should span for
* the masonry (row-span) layout. The grid uses `rowUnit`px rows separated by
* `rowGap`px, so a tile of height H occupies ceil((H + gap) / (unit + gap))
* rows. Kept pure for unit testing the packing math.
*/
export function heightToRowSpan(
height: number,
rowUnit: number,
rowGap: number,
): number {
return Math.max(1, Math.ceil((height + rowGap) / (rowUnit + rowGap)));
}
+33
View File
@@ -0,0 +1,33 @@
/**
* Shared types for the CardGridCanvas: a content-agnostic, N-column masonry of
* movable, variable-width, resizable tiles. The dashboard and Host Metrics both
* describe their layout with these primitives.
*/
export type GridColSpan = 1 | 2 | 3;
export interface GridSlot {
/** Stable card identifier (the consumer's card id). */
id: string;
/** Global order across the masonry; column placement is derived from order. */
order: number;
/** Tile width in grid columns (the "tetris" piece width). */
colSpan: GridColSpan;
/** Fixed pixel height, or null for content/auto height. */
height: number | null;
}
export interface GridLayout {
slots: GridSlot[];
/** Number of masonry columns (1..4). */
columns: number;
}
/** Catalog entry describing an addable card, used by the edit-mode Add tray. */
export interface GridCardCatalogEntry {
id: string;
label: string;
description?: string;
defaultColSpan?: GridColSpan;
defaultHeight?: number | null;
}
+55
View File
@@ -0,0 +1,55 @@
import { cn } from "@/lib/utils";
export interface BarSeriesItem {
label: string;
/** 0..max value driving the bar width. */
value: number;
/** Optional right-aligned value text (defaults to value). */
valueLabel?: string;
}
export interface BarSeriesProps {
items: BarSeriesItem[];
/** Domain max; defaults to the largest value (min 1). */
max?: number;
className?: string;
colorClassName?: string;
}
/** Compact horizontal bar list with a label, a track, and a value. */
export function BarSeries({
items,
max,
className,
colorClassName = "bg-accent-brand",
}: BarSeriesProps) {
const domainMax = max ?? Math.max(1, ...items.map((i) => i.value));
return (
<div className={cn("flex flex-col gap-2", className)}>
{items.map((item, i) => {
const pct = Math.min(100, Math.max(0, (item.value / domainMax) * 100));
return (
<div key={`${item.label}-${i}`} className="flex flex-col gap-1">
<div className="flex items-center justify-between gap-2">
<span className="truncate text-xs text-muted-foreground">
{item.label}
</span>
<span className="shrink-0 font-mono text-[11px] font-semibold">
{item.valueLabel ?? item.value}
</span>
</div>
<div className="h-1.5 w-full overflow-hidden bg-muted">
<div
className={cn(
"h-full transition-all duration-500",
colorClassName,
)}
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
})}
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
import { cn } from "@/lib/utils";
import { gaugeArc } from "./geometry";
export interface RadialGaugeProps {
/** 0..100 percentage, or null for "no data". */
value: number | null;
/** Large centered text (defaults to the percentage). */
label?: string;
/** Small caption under the value. */
caption?: string;
size?: number;
strokeWidth?: number;
className?: string;
/** Tailwind text-color class for the value arc. */
colorClassName?: string;
}
/**
* A 270-degree radial gauge with a big centered readout. Auto-themes via
* currentColor; the track uses the muted token.
*/
export function RadialGauge({
value,
label,
caption,
size = 120,
strokeWidth = 9,
className,
colorClassName = "text-accent-brand",
}: RadialGaugeProps) {
const pct = value === null ? 0 : Math.min(100, Math.max(0, value));
const radius = (size - strokeWidth) / 2;
const cx = size / 2;
const cy = size / 2;
// 270-degree sweep starting at the bottom-left (135deg), going clockwise.
const startAngle = 135;
const { trackLen, valueLen, circumference } = gaugeArc(value, radius, 270);
const display = label ?? (value === null ? "N/A" : `${Math.round(pct)}%`);
return (
<div
className={cn("relative shrink-0", className)}
style={{ width: size, height: size }}
>
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
className={colorClassName}
style={{ transform: `rotate(${startAngle}deg)` }}
aria-hidden="true"
>
<circle
cx={cx}
cy={cy}
r={radius}
fill="none"
strokeWidth={strokeWidth}
className="stroke-muted"
strokeDasharray={`${trackLen} ${circumference}`}
strokeLinecap="round"
/>
<circle
cx={cx}
cy={cy}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeDasharray={`${valueLen} ${circumference}`}
strokeLinecap="round"
className="transition-[stroke-dasharray] duration-500"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span
className={cn(
"text-xl font-bold leading-none md:text-2xl",
value === null ? "text-muted-foreground" : colorClassName,
)}
>
{display}
</span>
{caption && (
<span className="mt-1 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{caption}
</span>
)}
</div>
</div>
);
}
+89
View File
@@ -0,0 +1,89 @@
import { useId } from "react";
import { cn } from "@/lib/utils";
import { sparklineGeometry, SPARKLINE_VIEW_W } from "./geometry";
export interface SparklineProps {
/** Series of values, oldest first. */
data: Array<number | null | undefined>;
/**
* Fixed value domain [min, max]. When omitted the domain is derived from the
* data. Providing a stable domain (e.g. [0, 100] for percentages) avoids the
* jittery rescaling the old sparkline had.
*/
domain?: [number, number];
className?: string;
/** Tailwind text-color class for the line/fill (uses currentColor). */
colorClassName?: string;
/** Show a dot on the last point. */
showLastDot?: boolean;
/** Internal coordinate height; the SVG scales to its container. */
height?: number;
}
/**
* Compact area + line chart. Uses a non-scaling stroke + a stable value domain
* so the line stays crisp and readable regardless of container size.
*/
export function Sparkline({
data,
domain,
className,
colorClassName = "text-accent-brand",
showLastDot = true,
height = 56,
}: SparklineProps) {
const gradientId = useId();
const { hasData, coords, linePath, areaPath } = sparklineGeometry(
data,
height,
domain,
);
const last = coords[coords.length - 1];
return (
<div
className={cn(
"relative w-full overflow-hidden border border-border/50 bg-muted/20",
colorClassName,
className,
)}
style={{ height }}
>
{hasData && (
<svg
className="absolute inset-0 h-full w-full"
viewBox={`0 0 ${SPARKLINE_VIEW_W} ${height}`}
preserveAspectRatio="none"
aria-hidden="true"
>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="currentColor" stopOpacity="0.28" />
<stop offset="100%" stopColor="currentColor" stopOpacity="0" />
</linearGradient>
</defs>
<path d={areaPath} fill={`url(#${gradientId})`} />
<path
d={linePath}
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinejoin="round"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
className="opacity-80"
/>
{showLastDot && last && (
<circle
cx={last[0]}
cy={last[1]}
r={2.5}
fill="currentColor"
vectorEffect="non-scaling-stroke"
/>
)}
</svg>
)}
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
/** Dense key/value row used across metric and manager cards. */
export function StatRow({
label,
value,
mono = false,
className,
}: {
label: ReactNode;
value: ReactNode;
mono?: boolean;
className?: string;
}) {
return (
<div
className={cn(
"flex items-center justify-between gap-3 py-1.5 text-xs",
className,
)}
>
<span className="shrink-0 text-muted-foreground">{label}</span>
<span
className={cn(
"min-w-0 truncate text-right font-semibold",
mono && "font-mono",
)}
>
{value}
</span>
</div>
);
}
/** A compact stacked stat: small uppercase caption over a bold value. */
export function MiniStat({
caption,
value,
valueClassName,
className,
}: {
caption: ReactNode;
value: ReactNode;
valueClassName?: string;
className?: string;
}) {
return (
<div className={cn("flex flex-col gap-0.5", className)}>
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{caption}
</span>
<span className={cn("text-sm font-bold leading-none", valueClassName)}>
{value}
</span>
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { describe, it, expect } from "vitest";
import { sparklineGeometry, gaugeArc, SPARKLINE_VIEW_W } from "./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);
});
});
+66
View File
@@ -0,0 +1,66 @@
/** Pure geometry helpers for the chart primitives (unit-tested). */
export const SPARKLINE_VIEW_W = 300;
export interface SparklineGeometry {
hasData: boolean;
coords: Array<readonly [number, number]>;
linePath: string;
areaPath: string;
}
/**
* Map a value series to SVG coordinates within [0..VIEW_W] x [0..height].
* A fixed `domain` keeps the y-axis stable (no per-tick rescaling).
*/
export function sparklineGeometry(
data: Array<number | null | undefined>,
height: number,
domain?: [number, number],
): SparklineGeometry {
const points = data.map((v) =>
typeof v === "number" && isFinite(v) ? v : 0,
);
const hasData = points.length >= 2;
if (!hasData)
return { hasData: false, coords: [], linePath: "", areaPath: "" };
const lo = domain ? domain[0] : Math.min(...points);
const hiRaw = domain ? domain[1] : Math.max(...points);
const hi = hiRaw === lo ? lo + 1 : hiRaw;
const range = hi - lo;
const coords = points.map((v, i) => {
const x = (i / (points.length - 1)) * SPARKLINE_VIEW_W;
const clamped = Math.min(hi, Math.max(lo, v));
const y = height - ((clamped - lo) / range) * height;
return [x, y] as const;
});
const line = coords.map(([x, y]) => `${x},${y}`).join(" ");
return {
hasData: true,
coords,
linePath: `M ${line}`,
areaPath: `M 0,${height} L ${line} L ${SPARKLINE_VIEW_W},${height} Z`,
};
}
export interface GaugeArc {
trackLen: number;
valueLen: number;
circumference: number;
}
/** Arc lengths for a radial gauge sweeping `sweepDeg` degrees. */
export function gaugeArc(
value: number | null,
radius: number,
sweepDeg = 270,
): GaugeArc {
const pct = value === null ? 0 : Math.min(100, Math.max(0, value));
const circumference = 2 * Math.PI * radius;
const trackLen = circumference * (sweepDeg / 360);
const valueLen = trackLen * (pct / 100);
return { trackLen, valueLen, circumference };
}
+8
View File
@@ -0,0 +1,8 @@
export { Sparkline, type SparklineProps } from "./Sparkline";
export { RadialGauge, type RadialGaugeProps } from "./RadialGauge";
export {
BarSeries,
type BarSeriesItem,
type BarSeriesProps,
} from "./BarSeries";
export { StatRow, MiniStat } from "./StatRow";
+248
View File
@@ -0,0 +1,248 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
Folder,
FolderGit2,
FolderOpen,
Server,
ServerCog,
Cloud,
CloudCog,
Database,
HardDrive,
Box,
Boxes,
Container,
Network,
Router,
Wifi,
Globe,
Globe2,
Terminal,
Cpu,
MemoryStick,
Shield,
ShieldCheck,
Lock,
Key,
Rocket,
FlaskConical,
Wrench,
Hammer,
Gauge,
Activity,
Layers,
Building,
Building2,
Home,
Briefcase,
Code,
GitBranch,
Bug,
Settings,
Cog,
Star,
Flag,
Bookmark,
Tag,
Users,
User,
Zap,
Flame,
Leaf,
Mail,
Monitor,
Smartphone,
Search,
type LucideIcon,
} from "lucide-react";
import { Input } from "@/components/input";
export const FOLDER_ICON_MAP: Record<string, LucideIcon> = {
folder: Folder,
"folder-open": FolderOpen,
"folder-git": FolderGit2,
server: Server,
"server-cog": ServerCog,
cloud: Cloud,
"cloud-cog": CloudCog,
database: Database,
"hard-drive": HardDrive,
box: Box,
boxes: Boxes,
container: Container,
network: Network,
router: Router,
wifi: Wifi,
globe: Globe,
"globe-2": Globe2,
terminal: Terminal,
cpu: Cpu,
memory: MemoryStick,
shield: Shield,
"shield-check": ShieldCheck,
lock: Lock,
key: Key,
rocket: Rocket,
flask: FlaskConical,
wrench: Wrench,
hammer: Hammer,
gauge: Gauge,
activity: Activity,
layers: Layers,
building: Building,
"building-2": Building2,
home: Home,
briefcase: Briefcase,
code: Code,
"git-branch": GitBranch,
bug: Bug,
settings: Settings,
cog: Cog,
star: Star,
flag: Flag,
bookmark: Bookmark,
tag: Tag,
users: Users,
user: User,
zap: Zap,
flame: Flame,
leaf: Leaf,
mail: Mail,
monitor: Monitor,
smartphone: Smartphone,
};
export const FOLDER_ICON_IDS = Object.keys(FOLDER_ICON_MAP);
export const DEFAULT_FOLDER_ICON = "folder";
export const DEFAULT_FOLDER_COLOR = "#f59145";
export const SUGGESTED_FOLDER_COLORS = [
"#f59145",
"#ef4444",
"#eab308",
"#22c55e",
"#14b8a6",
"#3b82f6",
"#6366f1",
"#a855f7",
"#ec4899",
"#6b7280",
];
export function FolderIconEl({
icon,
className,
style,
}: {
icon?: string | null;
className?: string;
style?: React.CSSProperties;
}) {
const Icon = FOLDER_ICON_MAP[icon ?? ""] ?? Folder;
return <Icon className={className} style={style} />;
}
const HEX_RE = /^#([0-9a-fA-F]{6})$/;
export function isValidHex(value: string): boolean {
return HEX_RE.test(value.trim());
}
export function HexColorPicker({
value,
onChange,
}: {
value: string;
onChange: (hex: string) => void;
}) {
const safe = isValidHex(value) ? value : DEFAULT_FOLDER_COLOR;
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<input
type="color"
value={safe}
onChange={(e) => onChange(e.target.value)}
className="h-8 w-10 shrink-0 cursor-pointer rounded-none border border-input bg-transparent p-0.5"
/>
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="#6b7280"
className="font-mono uppercase"
maxLength={7}
/>
</div>
<div className="flex flex-wrap gap-1.5">
{SUGGESTED_FOLDER_COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => onChange(c)}
className={`size-6 rounded-none border transition-all ${
value.toLowerCase() === c.toLowerCase()
? "ring-2 ring-offset-1 ring-offset-background ring-white/60"
: "opacity-80 hover:opacity-100"
}`}
style={{ backgroundColor: c }}
/>
))}
</div>
</div>
);
}
export function IconPicker({
value,
color,
onChange,
}: {
value: string;
color?: string;
onChange: (icon: string) => void;
}) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const filtered = query.trim()
? FOLDER_ICON_IDS.filter((id) =>
id.toLowerCase().includes(query.trim().toLowerCase()),
)
: FOLDER_ICON_IDS;
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 px-2.5 h-8 bg-muted/60 border border-border/60 rounded-none">
<Search className="size-3 text-muted-foreground/60 shrink-0" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("hosts.iconSearchPlaceholder")}
className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50 text-foreground min-w-0"
/>
</div>
<div className="grid grid-cols-8 gap-1.5 max-h-40 overflow-y-auto pr-1">
{filtered.map((id) => (
<button
key={id}
type="button"
onClick={() => onChange(id)}
title={id}
className={`flex items-center justify-center h-9 border transition-colors ${
value === id
? "border-accent-brand/50 bg-accent-brand/10"
: "border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground"
}`}
>
<FolderIconEl
icon={id}
className="size-4"
style={value === id ? { color } : undefined}
/>
</button>
))}
</div>
</div>
);
}
@@ -0,0 +1,378 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Server, RefreshCw, CheckSquare, Square, Download } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
import {
discoverProxmoxGuests,
bulkImportSSHHosts,
getSSHHosts,
} from "@/main-axios";
import type { SSHHostWithStatus } from "@/main-axios";
import type { ProxmoxGuest } from "@/types/proxmox";
interface ProxmoxDiscoverDialogProps {
open: boolean;
onClose: () => void;
hosts: SSHHostWithStatus[];
onHostsChanged: (hosts: SSHHostWithStatus[]) => void;
/** Pre-select a specific host and skip the host picker */
preselectedHostId?: number;
/** Credential to use for imported hosts */
defaultCredentialId?: number | null;
/** Username from the default credential */
defaultUsername?: string;
}
export function ProxmoxDiscoverDialog({
open,
onClose,
hosts,
onHostsChanged,
preselectedHostId,
defaultCredentialId,
defaultUsername,
}: ProxmoxDiscoverDialogProps) {
const { t } = useTranslation();
const [selectedHostId, setSelectedHostId] = useState<string>(
preselectedHostId ? String(preselectedHostId) : "",
);
const [discovering, setDiscovering] = useState(false);
const [guests, setGuests] = useState<ProxmoxGuest[] | null>(null);
const [discoveredCredentialId, setDiscoveredCredentialId] = useState<
number | null
>(null);
const [selected, setSelected] = useState<Set<number>>(new Set());
const [importing, setImporting] = useState(false);
// When opened from the dropdown (no preselectedHostId), only show Proxmox-enabled hosts
const sshHosts = hosts.filter(
(h) => !("isFolder" in h) && h.enableProxmox === true,
);
// The Proxmox host the discovery runs against — imported guests are grouped
// into a folder named after it. Use the same id resolution as discovery so
// it also works when launched directly from a host action (preselectedHostId).
const effectiveHostId =
preselectedHostId != null ? String(preselectedHostId) : selectedHostId;
const sourceHost = hosts.find(
(h) => !("isFolder" in h) && String(h.id) === effectiveHostId,
);
const importFolder = sourceHost?.name || "Proxmox";
function reset() {
if (!preselectedHostId) setSelectedHostId("");
setGuests(null);
setDiscoveredCredentialId(null);
setSelected(new Set());
setDiscovering(false);
setImporting(false);
}
async function handleDiscover() {
const hostId =
preselectedHostId ?? (selectedHostId ? Number(selectedHostId) : null);
if (!hostId) return;
setDiscovering(true);
setGuests(null);
setDiscoveredCredentialId(null);
setSelected(new Set());
try {
const result = await discoverProxmoxGuests(hostId);
setGuests(result.guests);
setDiscoveredCredentialId(result.credentialId ?? null);
setSelected(
new Set(
result.guests
.filter((g) => g.status === "running")
.map((g) => g.vmid),
),
);
} catch (err: any) {
toast.error(err?.message ?? t("hosts.proxmoxDiscoveryFailed"));
} finally {
setDiscovering(false);
}
}
async function handleImport() {
if (!guests || selected.size === 0) return;
setImporting(true);
try {
// Prefer explicitly configured credential, then fall back to the host's own credential
const credId = defaultCredentialId ?? discoveredCredentialId;
const toImport = guests
.filter((g) => selected.has(g.vmid))
.map((g) => ({
name: g.name,
ip: g.ip ?? "0.0.0.0",
port: g.connectionType === "rdp" ? 3389 : 22,
username: defaultUsername ?? "root",
folder: importFolder,
...(credId != null
? {
authType: "credential" as const,
credentialId: credId,
overrideCredentialUsername: true,
}
: { authType: "password" as const }),
enableTerminal: g.connectionType !== "rdp",
enableFileManager: g.connectionType !== "rdp",
enableTunnel: g.connectionType !== "rdp",
enableSsh: g.connectionType !== "rdp",
enableRdp: g.connectionType === "rdp",
enableDocker: g.enableDocker,
connectionType: g.connectionType,
tags: ["proxmox", g.type, g.node],
}));
const result = await bulkImportSSHHosts(toImport, false);
const updated = await getSSHHosts();
onHostsChanged(updated);
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
const msg = [
result.success
? t("hosts.proxmoxResultImported", { count: result.success })
: null,
result.updated
? t("hosts.proxmoxResultUpdated", { count: result.updated })
: null,
result.failed
? t("hosts.proxmoxResultFailed", { count: result.failed })
: null,
]
.filter(Boolean)
.join(", ");
toast.success(t("hosts.proxmoxImportComplete", { summary: msg }));
if (result.failed === 0) {
reset();
onClose();
}
} catch (err: any) {
toast.error(err?.message ?? t("hosts.proxmoxImportFailed"));
} finally {
setImporting(false);
}
}
const nodeGroups = guests
? guests.reduce(
(acc, g) => {
if (!acc[g.node]) acc[g.node] = [];
acc[g.node].push(g);
return acc;
},
{} as Record<string, ProxmoxGuest[]>,
)
: {};
const canDiscover = preselectedHostId != null || !!selectedHostId;
return (
<Dialog
open={open}
onOpenChange={(v) => {
if (!v) {
reset();
onClose();
}
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Server className="size-4" />
{t("hosts.proxmoxImportTitle")}
</DialogTitle>
<a
href="https://docs.termix.site/features/files-and-hosts/proxmox-import"
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
</DialogHeader>
<div className="space-y-3">
{/* Host selector — hidden when launched from a specific host */}
{!preselectedHostId && (
<div className="flex gap-2">
<Select
value={selectedHostId}
onValueChange={setSelectedHostId}
disabled={discovering}
>
<SelectTrigger className="flex-1 text-xs h-8">
<SelectValue placeholder={t("hosts.proxmoxSelectHost")} />
</SelectTrigger>
<SelectContent>
{sshHosts.map((h) => (
<SelectItem
key={h.id}
value={String(h.id)}
className="text-xs"
>
{h.name || h.ip}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="outline"
disabled={!canDiscover || discovering}
onClick={handleDiscover}
className="shrink-0"
>
<RefreshCw
className={`size-3.5 mr-1.5 ${discovering ? "animate-spin" : ""}`}
/>
{t("hosts.proxmoxDiscover")}
</Button>
</div>
)}
{/* When launched from a specific host, show Discover directly */}
{preselectedHostId && !guests && (
<Button
size="sm"
variant="outline"
disabled={discovering}
onClick={handleDiscover}
className="w-full"
>
<RefreshCw
className={`size-3.5 mr-1.5 ${discovering ? "animate-spin" : ""}`}
/>
{discovering
? t("hosts.proxmoxDiscovering")
: t("hosts.proxmoxDiscoverGuests")}
</Button>
)}
{/* Guest list */}
{guests !== null && (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{t("hosts.proxmoxGuestsSelected", {
count: guests.length,
selected: selected.size,
})}
</span>
<div className="flex gap-2">
<button
className="hover:text-foreground transition-colors"
onClick={() =>
setSelected(new Set(guests.map((g) => g.vmid)))
}
>
{t("hosts.proxmoxSelectAll")}
</button>
<span>·</span>
<button
className="hover:text-foreground transition-colors"
onClick={() => setSelected(new Set())}
>
{t("hosts.proxmoxDeselectAll")}
</button>
</div>
</div>
{guests.length === 0 ? (
<p className="text-xs text-muted-foreground py-4 text-center">
{t("hosts.proxmoxNoGuests")}
</p>
) : (
<div className="max-h-64 overflow-y-auto rounded-md border border-border divide-y divide-border">
{Object.entries(nodeGroups).map(([node, nodeGuests]) => (
<div key={node}>
<div className="px-2 py-1 text-[10px] font-medium text-muted-foreground bg-muted/50 uppercase tracking-wider">
{node}
</div>
{nodeGuests.map((g) => (
<button
key={g.vmid}
className="w-full flex items-center gap-2 px-2 py-1.5 text-xs hover:bg-accent transition-colors text-left"
onClick={() => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(g.vmid)) next.delete(g.vmid);
else next.add(g.vmid);
return next;
});
}}
>
{selected.has(g.vmid) ? (
<CheckSquare className="size-3.5 text-primary shrink-0" />
) : (
<Square className="size-3.5 text-muted-foreground shrink-0" />
)}
<span className="flex-1 truncate">{g.name}</span>
<span className="text-muted-foreground text-[10px] shrink-0">
{g.type.toUpperCase()} {g.vmid}
</span>
<span
className={`shrink-0 text-[10px] ${g.status === "running" ? "text-green-400" : "text-muted-foreground"}`}
>
{g.status}
</span>
{g.ip && (
<span className="text-muted-foreground text-[10px] shrink-0 font-mono">
{g.ip}
</span>
)}
</button>
))}
</div>
))}
</div>
)}
</div>
)}
</div>
<DialogFooter>
<Button
variant="ghost"
size="sm"
onClick={() => {
reset();
onClose();
}}
>
{t("common.cancel")}
</Button>
{guests !== null && selected.size > 0 && (
<Button size="sm" disabled={importing} onClick={handleImport}>
{importing ? (
<RefreshCw className="size-3.5 animate-spin mr-1.5" />
) : (
<Download className="size-3.5 mr-1.5" />
)}
{t("hosts.proxmoxImportButton", { count: selected.size })}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+1 -1
View File
@@ -34,7 +34,7 @@ export function SettingRow({
}: {
label: string;
badge?: string;
description?: string;
description?: React.ReactNode;
children: React.ReactNode;
}) {
return (
-887
View File
@@ -1,887 +0,0 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, { useEffect, useState, useRef, useCallback } from "react";
import { Auth } from "@/auth/LoginPage.tsx";
import { AlertManager } from "@/dashboard/panels/alerts/AlertManager.tsx";
import { Button } from "@/components/button.tsx";
import {
getUserInfo,
getDatabaseHealth,
getUptime,
getVersionInfo,
getSSHHosts,
getCredentials,
getRecentActivity,
resetRecentActivity,
getAllServerStatuses,
getServerMetricsById,
registerMetricsViewer,
sendMetricsHeartbeat,
getGuacamoleDpi,
getGuacamoleTokenFromHost,
isCurrentAuthInvalidationError,
type RecentActivityItem,
} from "@/main-axios.ts";
import { useSidebar } from "@/components/sidebar.tsx";
import { Separator } from "@/components/separator.tsx";
import { useTabs } from "@/shell/TabContext.tsx";
import { Kbd } from "@/components/kbd";
import { useTranslation } from "react-i18next";
import { Settings as SettingsIcon } from "lucide-react";
import { ServerOverviewCard } from "@/dashboard/cards/ServerOverviewCard";
import { RecentActivityCard } from "@/dashboard/cards/RecentActivityCard";
import { QuickActionsCard } from "@/dashboard/cards/QuickActionsCard";
import { ServerStatsCard } from "@/dashboard/cards/ServerStatsCard";
import { NetworkGraphCard } from "@/dashboard/cards/NetworkGraphCard";
import { useDashboardPreferences } from "@/dashboard/hooks/useDashboardPreferences";
import { DashboardSettingsDialog } from "@/dashboard/components/DashboardSettingsDialog";
import { SimpleLoader } from "@/lib/SimpleLoader";
interface DashboardProps {
onSelectView: (view: string) => void;
isAuthenticated: boolean;
authLoading: boolean;
onAuthSuccess: (authData: {
isAdmin: boolean;
username: string | null;
userId: string | null;
}) => void;
isTopbarOpen: boolean;
rightSidebarOpen?: boolean;
rightSidebarWidth?: number;
initialDbError?: string | null;
}
export function Dashboard({
isAuthenticated,
authLoading,
onAuthSuccess,
isTopbarOpen,
rightSidebarOpen = false,
rightSidebarWidth = 400,
initialDbError = null,
}: DashboardProps): React.ReactElement {
const { t } = useTranslation();
const [loggedIn, setLoggedIn] = useState(isAuthenticated);
const [isAdmin, setIsAdmin] = useState(false);
const [, setUsername] = useState<string | null>(null);
const [userId, setUserId] = useState<string | null>(null);
const [, setDbError] = useState<string | null>(initialDbError);
const [uptime, setUptime] = useState<string>("0d 0h 0m");
const [versionStatus, setVersionStatus] = useState<
"up_to_date" | "requires_update" | "beta"
>("up_to_date");
const [versionText, setVersionText] = useState<string>("");
const [dbHealth, setDbHealth] = useState<"healthy" | "error">("healthy");
const [totalServers, setTotalServers] = useState<number>(0);
const [totalTunnels, setTotalTunnels] = useState<number>(0);
const [totalCredentials, setTotalCredentials] = useState<number>(0);
const [updateCheckDisabled, setUpdateCheckDisabled] = useState<boolean>(
localStorage.getItem("disableUpdateCheck") === "true",
);
const [recentActivity, setRecentActivity] = useState<RecentActivityItem[]>(
[],
);
const [recentActivityLoading, setRecentActivityLoading] =
useState<boolean>(true);
const [serverStats, setServerStats] = useState<
Array<{ id: number; name: string; cpu: number | null; ram: number | null }>
>([]);
const [serverStatsLoading, setServerStatsLoading] = useState<boolean>(true);
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
const [viewerSessions, setViewerSessions] = useState<Map<number, string>>(
new Map(),
);
const [initialLoading, setInitialLoading] = useState(true);
const { addTab, setCurrentTab, tabs: tabList } = useTabs();
const {
layout,
loading: preferencesLoading,
updateLayout,
resetLayout,
} = useDashboardPreferences(loggedIn);
const containerRef = useRef<HTMLDivElement>(null);
const mainWidthPct = layout?.mainWidthPct ?? 68;
const handleDividerMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
const startX = e.clientX;
const startPct = mainWidthPct;
const onMove = (ev: MouseEvent) => {
if (!containerRef.current) return;
const totalW = containerRef.current.getBoundingClientRect().width;
const delta = ev.clientX - startX;
const newPct = Math.min(
85,
Math.max(30, startPct + (delta / totalW) * 100),
);
if (layout) updateLayout({ ...layout, mainWidthPct: newPct });
};
const onUp = () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
},
[mainWidthPct, layout, updateLayout],
);
const handleCardHeightMouseDown = useCallback(
(e: React.MouseEvent, cardId: string, currentHeight: number) => {
e.preventDefault();
const startY = e.clientY;
const startH = currentHeight;
const onMove = (ev: MouseEvent) => {
const newH = Math.max(180, startH + (ev.clientY - startY));
if (!layout) return;
updateLayout({
...layout,
cards: layout.cards.map((c) =>
c.id === cardId ? { ...c, height: Math.round(newH) } : c,
),
});
};
const onUp = () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
},
[layout, updateLayout],
);
let sidebarState: "expanded" | "collapsed" = "expanded";
try {
const sidebar = useSidebar();
sidebarState = sidebar.state;
} catch {
// Sidebar context is not available on every dashboard mount path.
}
const topMarginPx = isTopbarOpen ? 74 : 26;
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
const rightMarginPx = 17;
const bottomMarginPx = 8;
useEffect(() => {
setLoggedIn(isAuthenticated);
}, [isAuthenticated]);
useEffect(() => {
if (isAuthenticated) {
getUserInfo()
.then((meRes) => {
setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null);
setUserId(meRes.userId || null);
setDbError(null);
})
.catch((err) => {
if (isCurrentAuthInvalidationError(err)) {
setIsAdmin(false);
setUsername(null);
setUserId(null);
console.warn("Session expired - please log in again");
setDbError("Session expired - please log in again");
} else {
setDbError(null);
}
});
getDatabaseHealth()
.then(() => {
setDbError(null);
})
.catch((err) => {
if (err?.response?.data?.error?.includes("Database")) {
setDbError(
"Could not connect to the database. Please try again later.",
);
}
});
}
}, [isAuthenticated]);
useEffect(() => {
if (!loggedIn) return;
const fetchDashboardData = async () => {
try {
const uptimeInfo = await getUptime();
setUptime(uptimeInfo.formatted);
const updateDisabled =
localStorage.getItem("disableUpdateCheck") === "true";
setUpdateCheckDisabled(updateDisabled);
if (!updateDisabled) {
const versionInfo = await getVersionInfo();
setVersionText(`v${versionInfo.localVersion}`);
if (
versionInfo.status === "up_to_date" ||
versionInfo.status === "requires_update" ||
versionInfo.status === "beta"
) {
setVersionStatus(versionInfo.status);
}
} else {
const versionInfo = await getVersionInfo();
setVersionText(`v${versionInfo.localVersion}`);
}
try {
await getDatabaseHealth();
setDbHealth("healthy");
} catch {
setDbHealth("error");
}
const hostsResponse = await getSSHHosts();
const hosts = Array.isArray(hostsResponse) ? hostsResponse : [];
setTotalServers(hosts.length);
let totalTunnelsCount = 0;
for (const host of hosts) {
if (host.tunnelConnections) {
try {
const tunnelConnections = Array.isArray(host.tunnelConnections)
? host.tunnelConnections
: JSON.parse(host.tunnelConnections);
if (Array.isArray(tunnelConnections)) {
totalTunnelsCount += tunnelConnections.length;
}
} catch (error) {
console.error("Dashboard operation failed:", error);
}
}
}
setTotalTunnels(totalTunnelsCount);
const credentialsResponse = await getCredentials();
const credentials = Array.isArray(credentialsResponse)
? credentialsResponse
: [];
setTotalCredentials(credentials.length);
setRecentActivityLoading(true);
const activityResponse = await getRecentActivity(35);
const activity = Array.isArray(activityResponse)
? activityResponse
: [];
setRecentActivity(activity);
setRecentActivityLoading(false);
setServerStatsLoading(true);
// Fetch current host statuses once so we can skip offline hosts
// before issuing per-host register-viewer / metrics requests.
let hostStatuses: Record<number, { status?: string }> = {};
try {
hostStatuses = (await getAllServerStatuses()) as Record<
number,
{ status?: string }
>;
} catch {
// Best-effort: if the status endpoint is unavailable, fall back
// to the previous behavior and still attempt each host.
hostStatuses = {};
}
const newViewerSessions = new Map<number, string>();
const serversWithStats = await Promise.all(
hosts.slice(0, 50).map(
async (host: {
id: number;
name: string;
authType?: string;
statsConfig?:
| string
| {
metricsEnabled?: boolean;
statusCheckEnabled?: boolean;
};
}) => {
try {
let statsConfig: {
metricsEnabled?: boolean;
statusCheckEnabled?: boolean;
} = {
metricsEnabled: true,
statusCheckEnabled: true,
};
if (host.statsConfig) {
if (typeof host.statsConfig === "string") {
statsConfig = JSON.parse(host.statsConfig);
} else {
statsConfig = host.statsConfig;
}
}
if (statsConfig.metricsEnabled === false) {
return null;
}
if (host.authType === "none") {
return null;
}
if (host.authType === "opkssh") {
return null;
}
// Skip hosts that are known to be offline: no metrics can
// possibly exist for them, and hitting /metrics/:id would
// just 404. If the status is unknown (e.g. no entry yet
// or statusCheckEnabled === false) we still attempt.
if (statsConfig.statusCheckEnabled !== false) {
const knownStatus = hostStatuses?.[host.id]?.status;
if (knownStatus === "offline") {
return null;
}
}
const existingSession = viewerSessions.get(host.id);
let sessionId = existingSession;
let registrationSkipped = false;
if (!existingSession) {
try {
const viewerResult = await registerMetricsViewer(host.id);
if (viewerResult.skipped) {
// Metrics disabled/unsupported on this host; don't
// poll and don't surface this as an error.
registrationSkipped = true;
} else if (
viewerResult.success &&
viewerResult.viewerSessionId
) {
sessionId = viewerResult.viewerSessionId;
newViewerSessions.set(host.id, sessionId);
}
} catch (error) {
console.error(
`Failed to register viewer for host ${host.id}:`,
error,
);
}
} else {
newViewerSessions.set(host.id, existingSession);
}
if (registrationSkipped) {
return null;
}
const metrics = await getServerMetricsById(host.id);
if (!metrics) {
return {
id: host.id,
name: host.name || `Host ${host.id}`,
cpu: null,
ram: null,
};
}
return {
id: host.id,
name: host.name || `Host ${host.id}`,
cpu: metrics.cpu?.percent ?? null,
ram: metrics.memory?.percent ?? null,
};
} catch {
return {
id: host.id,
name: host.name || `Host ${host.id}`,
cpu: null,
ram: null,
};
}
},
),
);
setViewerSessions(newViewerSessions);
const validServerStats = serversWithStats.filter(
(
server,
): server is {
id: number;
name: string;
cpu: number | null;
ram: number | null;
} => server !== null,
);
setServerStats(validServerStats);
setServerStatsLoading(false);
} catch (error) {
console.error("Failed to fetch dashboard data:", error);
setRecentActivityLoading(false);
setServerStatsLoading(false);
} finally {
setInitialLoading(false);
}
};
fetchDashboardData();
const interval = setInterval(fetchDashboardData, 30000);
return () => clearInterval(interval);
}, [loggedIn]);
useEffect(() => {
if (!loggedIn || viewerSessions.size === 0) return;
const heartbeatInterval = setInterval(async () => {
for (const [, sessionId] of viewerSessions) {
try {
await sendMetricsHeartbeat(sessionId);
} catch (error) {
console.error("Failed to send heartbeat:", error);
}
}
}, 30000);
return () => clearInterval(heartbeatInterval);
}, [loggedIn, viewerSessions]);
const handleResetActivity = async () => {
try {
await resetRecentActivity();
setRecentActivity([]);
} catch (error) {
console.error("Failed to reset activity:", error);
}
};
const handleActivityClick = (item: RecentActivityItem) => {
getSSHHosts().then((hosts) => {
const host = hosts.find((h: { id: number }) => h.id === item.hostId);
if (!host) return;
if (item.type === "terminal") {
addTab({
type: "terminal",
title: item.hostName,
hostConfig: host,
});
} else if (item.type === "file_manager") {
addTab({
type: "file_manager",
title: item.hostName,
hostConfig: host,
});
} else if (item.type === "server_stats") {
addTab({
type: "server_stats",
title: item.hostName,
hostConfig: host,
});
} else if (item.type === "tunnel") {
addTab({
type: "tunnel",
title: item.hostName,
hostConfig: host,
});
} else if (item.type === "docker") {
addTab({
type: "docker",
title: item.hostName,
hostConfig: host,
});
} else if (item.type === "telnet") {
getGuacamoleTokenFromHost(host.id)
.then((result) => {
addTab({
type: "telnet",
title: item.hostName,
hostConfig: host,
connectionConfig: {
token: result.token,
protocol: "telnet",
type: "telnet",
hostname: host.ip,
port: host.port,
username: host.username,
password: host.password,
domain: host.domain,
security: host.security,
"ignore-cert": host.ignoreCert,
dpi: getGuacamoleDpi(host),
},
});
})
.catch((error) => {
console.error("Failed to get telnet token:", error);
});
} else if (item.type === "vnc") {
getGuacamoleTokenFromHost(host.id)
.then((result) => {
addTab({
type: "vnc",
title: item.hostName,
hostConfig: host,
connectionConfig: {
token: result.token,
protocol: "vnc",
type: "vnc",
hostname: host.ip,
port: host.port,
username: host.username,
password: host.password,
domain: host.domain,
security: host.security,
"ignore-cert": host.ignoreCert,
dpi: getGuacamoleDpi(host),
},
});
})
.catch((error) => {
console.error("Failed to get vnc token:", error);
});
} else if (item.type === "rdp") {
getGuacamoleTokenFromHost(host.id)
.then((result) => {
addTab({
type: "rdp",
title: item.hostName,
hostConfig: host,
connectionConfig: {
token: result.token,
protocol: "rdp",
type: "rdp",
hostname: host.ip,
port: host.port,
username: host.username,
password: host.password,
domain: host.domain,
security: host.security,
"ignore-cert": host.ignoreCert,
dpi: getGuacamoleDpi(host),
},
});
})
.catch((error) => {
console.error("Failed to get rdp token:", error);
});
}
});
};
const handleServerStatClick = (serverId: number, serverName: string) => {
getSSHHosts().then((hosts) => {
const host = hosts.find((h: { id: number }) => h.id === serverId);
if (!host) return;
addTab({
type: "server_stats",
title: serverName,
hostConfig: host,
});
});
};
const handleAddHost = () => {
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
if (sshManagerTab) {
setCurrentTab(sshManagerTab.id);
setTimeout(() => {
window.dispatchEvent(new CustomEvent("host-manager:add-host"));
}, 100);
} else {
const id = addTab({
type: "ssh_manager",
title: "Host Manager",
initialTab: "hosts",
});
setCurrentTab(id);
setTimeout(() => {
window.dispatchEvent(new CustomEvent("host-manager:add-host"));
}, 100);
}
};
const handleAddCredential = () => {
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
if (sshManagerTab) {
setCurrentTab(sshManagerTab.id);
setTimeout(() => {
window.dispatchEvent(new CustomEvent("host-manager:add-credential"));
}, 100);
} else {
const id = addTab({
type: "ssh_manager",
title: "Host Manager",
initialTab: "credentials",
});
setCurrentTab(id);
setTimeout(() => {
window.dispatchEvent(new CustomEvent("host-manager:add-credential"));
}, 100);
}
};
const handleOpenAdminSettings = () => {
const adminTab = tabList.find((t) => t.type === "admin");
if (adminTab) {
setCurrentTab(adminTab.id);
} else {
const id = addTab({ type: "admin", title: "Admin Settings" });
setCurrentTab(id);
}
};
const handleOpenUserProfile = () => {
const userProfileTab = tabList.find((t) => t.type === "user_profile");
if (userProfileTab) {
setCurrentTab(userProfileTab.id);
} else {
const id = addTab({ type: "user_profile", title: "User Profile" });
setCurrentTab(id);
}
};
return (
<>
{!loggedIn ? (
<div className="w-full h-full flex items-center justify-center">
<Auth
setLoggedIn={setLoggedIn}
setIsAdmin={setIsAdmin}
setUsername={setUsername}
setUserId={setUserId}
loggedIn={loggedIn}
authLoading={authLoading}
setDbError={setDbError}
onAuthSuccess={onAuthSuccess}
/>
</div>
) : (
<div
className="bg-canvas text-foreground rounded-lg border-2 border-edge overflow-hidden flex min-w-0"
style={{
marginLeft: leftMarginPx,
marginRight: rightSidebarOpen
? `calc(var(--right-sidebar-width, ${rightSidebarWidth}px) + 8px)`
: rightMarginPx,
marginTop: topMarginPx,
marginBottom: bottomMarginPx,
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
transition:
"margin-left 200ms linear, margin-right 200ms linear, margin-top 200ms linear",
}}
>
<div className="flex flex-col relative z-10 w-full h-full min-w-0">
<SimpleLoader
visible={initialLoading}
message={t("dashboard.loading")}
/>
<div className="flex flex-row items-center justify-between w-full px-3 mt-3 min-w-0 flex-wrap gap-2">
<div className="flex flex-row items-center gap-3">
<div className="text-2xl text-foreground font-semibold shrink-0">
{t("dashboard.title")}
</div>
</div>
<div className="flex flex-row gap-3 flex-wrap min-w-0">
<div className="flex flex-col items-center gap-4 justify-center mr-5 min-w-0 shrink">
<p className="text-muted-foreground text-sm whitespace-nowrap">
Press <Kbd>L Shift</Kbd> twice to open the command palette
</p>
</div>
<Button
className="font-semibold shrink-0 !bg-canvas"
variant="outline"
onClick={() =>
window.open(
"https://github.com/Termix-SSH/Termix",
"_blank",
)
}
>
{t("dashboard.github")}
</Button>
<Button
className="font-semibold shrink-0 !bg-canvas"
variant="outline"
onClick={() =>
window.open(
"https://github.com/Termix-SSH/Support/issues/new",
"_blank",
)
}
>
{t("dashboard.support")}
</Button>
<Button
className="font-semibold shrink-0 !bg-canvas"
variant="outline"
onClick={() =>
window.open(
"https://discord.com/invite/jVQGdvHDrf",
"_blank",
)
}
>
{t("dashboard.discord")}
</Button>
<Button
className="font-semibold shrink-0 !bg-canvas"
variant="outline"
onClick={() => setSettingsDialogOpen(true)}
>
<SettingsIcon />
</Button>
</div>
</div>
<Separator className="mt-3 p-0.25" />
<div
ref={containerRef}
className="flex flex-row flex-1 my-5 mx-5 gap-0 min-h-0 min-w-0 overflow-hidden"
>
{!preferencesLoading &&
layout &&
(() => {
const enabledCards = layout.cards
.filter((card) => card.enabled)
.sort((a, b) => a.order - b.order);
const mainCards = enabledCards.filter(
(c) => (c.panel ?? "main") === "main",
);
const sideCards = enabledCards.filter(
(c) => c.panel === "side",
);
const renderCard = (card: (typeof enabledCards)[0]) => {
if (card.id === "server_overview") {
return (
<ServerOverviewCard
key={card.id}
loggedIn={loggedIn}
versionText={versionText}
versionStatus={versionStatus}
uptime={uptime}
dbHealth={dbHealth}
totalServers={totalServers}
totalTunnels={totalTunnels}
totalCredentials={totalCredentials}
updateCheckDisabled={updateCheckDisabled}
/>
);
} else if (card.id === "recent_activity") {
return (
<RecentActivityCard
key={card.id}
activities={recentActivity}
loading={recentActivityLoading}
onReset={handleResetActivity}
onActivityClick={handleActivityClick}
/>
);
} else if (card.id === "network_graph") {
return (
<NetworkGraphCard
key={card.id}
isTopbarOpen={isTopbarOpen}
rightSidebarOpen={rightSidebarOpen}
rightSidebarWidth={rightSidebarWidth}
/>
);
} else if (card.id === "quick_actions") {
return (
<QuickActionsCard
key={card.id}
isAdmin={isAdmin}
onAddHost={handleAddHost}
onAddCredential={handleAddCredential}
onOpenAdminSettings={handleOpenAdminSettings}
onOpenUserProfile={handleOpenUserProfile}
/>
);
} else if (card.id === "server_stats") {
return (
<ServerStatsCard
key={card.id}
serverStats={serverStats}
loading={serverStatsLoading}
onServerClick={handleServerStatClick}
/>
);
}
return null;
};
const renderCardWithHandle = (
card: (typeof enabledCards)[0],
) => {
const currentHeight = card.height ?? 280;
return (
<div
key={card.id}
className="flex flex-col"
style={
card.height
? { height: card.height, flexShrink: 0 }
: { flex: 1, minHeight: 280 }
}
>
<div className="flex-1 min-h-0">{renderCard(card)}</div>
<div
className="flex-shrink-0 h-2 my-0.5 flex items-center justify-center cursor-row-resize group"
onMouseDown={(e) =>
handleCardHeightMouseDown(e, card.id, currentHeight)
}
>
<div className="w-16 h-0.5 rounded-full bg-border group-hover:bg-primary/50 transition-colors" />
</div>
</div>
);
};
return (
<>
<div
className="flex flex-col gap-2 overflow-auto min-h-0"
style={{ width: `${mainWidthPct}%`, minWidth: 0 }}
>
{mainCards.map((card) => renderCardWithHandle(card))}
</div>
<div
className="flex-shrink-0 w-2 mx-1.5 flex items-center justify-center cursor-col-resize group"
onMouseDown={handleDividerMouseDown}
>
<div className="w-0.5 h-16 rounded-full bg-border group-hover:bg-primary/50 transition-colors" />
</div>
<div
className="flex flex-col gap-2 overflow-auto min-h-0"
style={{ flex: 1, minWidth: 0 }}
>
{sideCards.map((card) => renderCardWithHandle(card))}
</div>
</>
);
})()}
</div>
</div>
</div>
)}
<AlertManager userId={userId} loggedIn={loggedIn} />
{layout && (
<DashboardSettingsDialog
open={settingsDialogOpen}
onOpenChange={setSettingsDialogOpen}
currentLayout={layout}
onSave={updateLayout}
onReset={resetLayout}
/>
)}
</>
);
}
+35 -3
View File
@@ -403,7 +403,7 @@ function HostStatusCard({
return (
<div
key={i}
onClick={() => onOpenTab(host, "stats")}
onClick={() => onOpenTab(host, "host-metrics")}
className="flex items-center justify-between px-4 py-2.5 border-b border-border last:border-0 hover:bg-muted/50 cursor-pointer"
>
<div className="flex items-center gap-2.5">
@@ -508,7 +508,7 @@ function RecentActivityCard({
const typeToTab: Record<RecentActivityItem["type"], TabType> = {
terminal: "terminal",
file_manager: "files",
server_stats: "stats",
server_stats: "host-metrics",
tunnel: "tunnel",
docker: "docker",
rdp: "rdp",
@@ -1120,7 +1120,11 @@ export function DashboardTab({
getCredentials()
.then((res) =>
setCredentialCount(
Array.isArray(res?.credentials) ? res.credentials.length : 0,
Array.isArray(res)
? res.length
: Array.isArray(res?.credentials)
? res.credentials.length
: 0,
),
)
.catch(() => {});
@@ -1354,6 +1358,20 @@ export function DashboardTab({
{t("dashboard.discord")}
</a>
</Button>
<Button
variant="ghost"
size="sm"
className="text-xs text-muted-foreground hover:text-foreground"
asChild
>
<a
href="https://docs.termix.site/"
target="_blank"
rel="noreferrer"
>
{t("dashboard.docs")}
</a>
</Button>
</div>
</Card>
{allSlots.map((slot) => (
@@ -1475,6 +1493,20 @@ export function DashboardTab({
{t("dashboard.discord")}
</a>
</Button>
<Button
variant="ghost"
size="sm"
className="text-xs text-muted-foreground hover:text-foreground"
asChild
>
<a
href="https://docs.termix.site/"
target="_blank"
rel="noreferrer"
>
{t("dashboard.docs")}
</a>
</Button>
<Separator orientation="vertical" className="mx-1 h-5" />
{editMode ? (
<>
+22 -2
View File
@@ -43,6 +43,7 @@ import {
RotateCw,
AlertCircle,
Download,
ExternalLink,
Upload,
Link2,
FolderPlus,
@@ -806,11 +807,11 @@ export function NetworkGraphCard({
</button>
)}
<button
onClick={() => handleConnectAction("stats")}
onClick={() => handleConnectAction("host-metrics")}
className="flex items-center gap-2 px-3 py-2 text-xs w-full text-left hover:bg-muted transition-colors"
>
<Server className="size-3 shrink-0" />
{t("networkGraph.serverStats")}
{t("networkGraph.hostMetrics")}
</button>
{!embedded && (
<>
@@ -1303,6 +1304,16 @@ export function NetworkGraphCard({
<Upload className="size-3.5" />
</button>
</div>
<div className="w-px h-5 bg-border mx-1" />
<a
href="https://docs.termix.site/features/dashboard/network-graph"
target="_blank"
rel="noreferrer"
title={t("hosts.docsLink")}
className={btnCls}
>
<ExternalLink className="size-3.5" />
</a>
</div>
{cytoscapeEl}
{dialogs}
@@ -1355,6 +1366,15 @@ export function NetworkGraphCard({
>
<ArrowUp className="size-3" />
</button>
<a
href="https://docs.termix.site/features/dashboard/network-graph"
target="_blank"
rel="noreferrer"
title={t("hosts.docsLink")}
className={btnCls}
>
<ExternalLink className="size-3" />
</a>
</div>
</div>
{cytoscapeEl}
-141
View File
@@ -1,141 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { FastForward, Server, Key, Settings, User } from "lucide-react";
import { Button } from "@/components/button";
interface QuickActionsCardProps {
isAdmin: boolean;
onAddHost: () => void;
onAddCredential: () => void;
onOpenAdminSettings: () => void;
onOpenUserProfile: () => void;
}
export function QuickActionsCard({
isAdmin,
onAddHost,
onAddCredential,
onOpenAdminSettings,
onOpenUserProfile,
}: QuickActionsCardProps): React.ReactElement {
const { t } = useTranslation();
return (
<div className="border-2 border-edge rounded-md flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20 !bg-elevated">
<div className="flex flex-col mx-3 my-2 overflow-y-auto overflow-x-hidden thin-scrollbar">
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
<FastForward className="mr-3" />
{t("dashboard.quickActions")}
</p>
<div className="grid gap-4 grid-cols-3 auto-rows-min overflow-y-auto overflow-x-hidden thin-scrollbar">
<Button
variant="outline"
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 !bg-canvas"
onClick={onAddHost}
>
<div className="flex flex-col items-center w-full max-w-full">
<Server
className="shrink-0"
style={{ width: "40px", height: "40px" }}
/>
<span
className="font-semibold text-sm mt-2 text-center block"
style={{
wordWrap: "break-word",
overflowWrap: "break-word",
width: "100%",
maxWidth: "100%",
hyphens: "auto",
display: "block",
whiteSpace: "normal",
}}
>
{t("dashboard.addHost")}
</span>
</div>
</Button>
<Button
variant="outline"
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 !bg-canvas"
onClick={onAddCredential}
>
<div className="flex flex-col items-center w-full max-w-full">
<Key
className="shrink-0"
style={{ width: "40px", height: "40px" }}
/>
<span
className="font-semibold text-sm mt-2 text-center block"
style={{
wordWrap: "break-word",
overflowWrap: "break-word",
width: "100%",
maxWidth: "100%",
hyphens: "auto",
display: "block",
whiteSpace: "normal",
}}
>
{t("dashboard.addCredential")}
</span>
</div>
</Button>
{isAdmin && (
<Button
variant="outline"
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 !bg-canvas"
onClick={onOpenAdminSettings}
>
<div className="flex flex-col items-center w-full max-w-full">
<Settings
className="shrink-0"
style={{ width: "40px", height: "40px" }}
/>
<span
className="font-semibold text-sm mt-2 text-center block"
style={{
wordWrap: "break-word",
overflowWrap: "break-word",
width: "100%",
maxWidth: "100%",
hyphens: "auto",
display: "block",
whiteSpace: "normal",
}}
>
{t("dashboard.adminSettings")}
</span>
</div>
</Button>
)}
<Button
variant="outline"
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 !bg-canvas"
onClick={onOpenUserProfile}
>
<div className="flex flex-col items-center w-full max-w-full">
<User
className="shrink-0"
style={{ width: "40px", height: "40px" }}
/>
<span
className="font-semibold text-sm mt-2 text-center block"
style={{
wordWrap: "break-word",
overflowWrap: "break-word",
width: "100%",
maxWidth: "100%",
hyphens: "auto",
display: "block",
whiteSpace: "normal",
}}
>
{t("dashboard.userProfile")}
</span>
</div>
</Button>
</div>
</div>
</div>
);
}
@@ -1,128 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import {
Clock,
Loader2,
Terminal,
FolderOpen,
Server,
ArrowDownUp,
Container,
Monitor,
Eye,
MessagesSquare,
} from "lucide-react";
import { Button } from "@/components/button";
import { type RecentActivityItem } from "@/main-axios";
interface RecentActivityCardProps {
activities: RecentActivityItem[];
loading: boolean;
onReset: () => void;
onActivityClick: (item: RecentActivityItem) => void;
}
function formatRelativeTime(
timestamp: string,
t: (key: string) => string,
): string {
const diffMs = Date.now() - new Date(timestamp).getTime();
if (diffMs < 0) return t("dashboard.justNow");
const diffSec = Math.floor(diffMs / 1000);
if (diffSec < 60) return t("dashboard.justNow");
const diffMin = Math.floor(diffSec / 60);
if (diffMin < 60) return `${diffMin}m`;
const diffHour = Math.floor(diffMin / 60);
if (diffHour < 24) return `${diffHour}h`;
return `${Math.floor(diffHour / 24)}d`;
}
export function RecentActivityCard({
activities,
loading,
onReset,
onActivityClick,
}: RecentActivityCardProps): React.ReactElement {
const { t } = useTranslation();
return (
<div className="border-2 border-edge rounded-md flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20 !bg-elevated">
<div className="flex flex-col mx-3 my-2 flex-1 overflow-hidden">
<div className="flex flex-row items-center justify-between mb-3 mt-1">
<p className="text-xl font-semibold flex flex-row items-center">
<Clock className="mr-3" />
{t("dashboard.recentActivity")}
</p>
<Button
variant="outline"
size="sm"
className="border-2 !border-edge h-7 !bg-canvas"
onClick={onReset}
>
{t("dashboard.reset")}
</Button>
</div>
<div
className={`grid gap-4 grid-cols-3 auto-rows-min overflow-x-hidden thin-scrollbar ${loading ? "overflow-y-hidden" : "overflow-y-auto"}`}
>
{loading ? (
<div className="flex flex-row items-center text-muted-foreground text-sm animate-pulse">
<Loader2 className="animate-spin mr-2" size={16} />
<span>{t("dashboard.loadingRecentActivity")}</span>
</div>
) : activities.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("dashboard.noRecentActivity")}
</p>
) : (
activities
.filter((item, index, array) => {
if (index === 0) return true;
const prevItem = array[index - 1];
return !(
item.hostId === prevItem.hostId && item.type === prevItem.type
);
})
.map((item) => (
<Button
key={item.id}
variant="outline"
className="border-2 !border-edge min-w-0 !bg-canvas"
onClick={() => onActivityClick(item)}
>
{item.type === "terminal" ? (
<Terminal size={20} className="shrink-0" />
) : item.type === "file_manager" ? (
<FolderOpen size={20} className="shrink-0" />
) : item.type === "server_stats" ? (
<Server size={20} className="shrink-0" />
) : item.type === "tunnel" ? (
<ArrowDownUp size={20} className="shrink-0" />
) : item.type === "docker" ? (
<Container size={20} className="shrink-0" />
) : item.type === "telnet" ? (
<MessagesSquare size={20} className="shrink-0" />
) : item.type === "vnc" ? (
<Eye size={20} className="shrink-0" />
) : item.type === "rdp" ? (
<Monitor size={20} className="shrink-0" />
) : (
<Terminal size={20} className="shrink-0" />
)}
<div className="flex flex-col items-start min-w-0 ml-2">
<p className="truncate font-semibold leading-none">
{item.hostName}
</p>
<p className="text-xs text-muted-foreground mt-0.5 leading-none">
{formatRelativeTime(item.timestamp, t)}
</p>
</div>
</Button>
))
)}
</div>
</div>
</div>
);
}
@@ -1,156 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import {
Server,
History,
Clock,
Database,
Key,
ArrowDownUp,
} from "lucide-react";
import { Button } from "@/components/button";
import { UpdateLog } from "@/dashboard/panels/UpdateLog";
interface ServerOverviewCardProps {
loggedIn: boolean;
versionText: string;
versionStatus: "up_to_date" | "requires_update" | "beta";
uptime: string;
dbHealth: "healthy" | "error";
totalServers: number;
totalTunnels: number;
totalCredentials: number;
updateCheckDisabled?: boolean;
}
export function ServerOverviewCard({
loggedIn,
versionText,
versionStatus,
uptime,
dbHealth,
totalServers,
totalTunnels,
totalCredentials,
updateCheckDisabled = false,
}: ServerOverviewCardProps): React.ReactElement {
const { t } = useTranslation();
return (
<div className="border-2 border-edge rounded-md flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20 !bg-elevated">
<div className="flex flex-col mx-3 my-2 overflow-y-auto overflow-x-hidden thin-scrollbar">
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
<Server className="mr-3" />
{t("dashboard.serverOverview")}
</p>
<div className="w-full h-auto border-2 border-edge rounded-md px-3 py-3 !bg-canvas">
<div className="flex flex-row items-center justify-between mb-3 min-w-0 gap-2">
<div className="flex flex-row items-center min-w-0">
<History size={20} className="shrink-0" />
<p className="ml-2 leading-none truncate">
{t("dashboard.version")}
</p>
</div>
<div className="flex flex-row items-center">
<p className="leading-none text-muted-foreground">
{versionText}
</p>
{versionStatus === "beta" ? (
<Button
variant="outline"
size="sm"
className="ml-2 text-sm border-1 border-edge text-blue-400"
>
{t("dashboard.beta")}
</Button>
) : !updateCheckDisabled ? (
<>
<Button
variant="outline"
size="sm"
className={`ml-2 text-sm border-1 border-edge ${versionStatus === "up_to_date" ? "text-green-400" : "text-yellow-400"}`}
>
{versionStatus === "up_to_date"
? t("dashboard.upToDate")
: t("dashboard.updateAvailable")}
</Button>
<UpdateLog loggedIn={loggedIn} />
</>
) : null}
</div>
</div>
<div className="flex flex-row items-center justify-between mb-5 min-w-0 gap-2">
<div className="flex flex-row items-center min-w-0">
<Clock size={20} className="shrink-0" />
<p className="ml-2 leading-none truncate">
{t("dashboard.uptime")}
</p>
</div>
<div className="flex flex-row items-center">
<p className="leading-none text-muted-foreground">{uptime}</p>
</div>
</div>
<div className="flex flex-row items-center justify-between min-w-0 gap-2">
<div className="flex flex-row items-center min-w-0">
<Database size={20} className="shrink-0" />
<p className="ml-2 leading-none truncate">
{t("dashboard.database")}
</p>
</div>
<div className="flex flex-row items-center">
<p
className={`leading-none ${dbHealth === "healthy" ? "text-green-400" : "text-red-400"}`}
>
{dbHealth === "healthy"
? t("dashboard.healthy")
: t("dashboard.error")}
</p>
</div>
</div>
</div>
<div className="flex flex-col grid grid-cols-2 gap-2 mt-2">
<div className="flex flex-row items-center justify-between w-full h-auto mt-3 border-2 border-edge rounded-md px-3 py-3 min-w-0 gap-2 !bg-canvas">
<div className="flex flex-row items-center min-w-0">
<Server size={16} className="mr-3 shrink-0" />
<p className="m-0 leading-none truncate">
{t("dashboard.totalHosts")}
</p>
</div>
<p className="m-0 leading-none text-muted-foreground font-semibold">
{totalServers}
</p>
</div>
<div className="flex flex-row items-center justify-between w-full h-auto mt-3 border-2 border-edge rounded-md px-3 py-3 min-w-0 gap-2 !bg-canvas">
<div className="flex flex-row items-center min-w-0">
<ArrowDownUp size={16} className="mr-3 shrink-0" />
<p className="m-0 leading-none truncate">
{t("dashboard.totalTunnels")}
</p>
</div>
<p className="m-0 leading-none text-muted-foreground font-semibold">
{totalTunnels}
</p>
</div>
</div>
<div className="flex flex-col grid grid-cols-2 gap-2 mt-2">
<div className="flex flex-row items-center justify-between w-full h-auto mt-3 border-2 border-edge rounded-md px-3 py-3 min-w-0 gap-2 !bg-canvas">
<div className="flex flex-row items-center min-w-0">
<Key size={16} className="mr-3 shrink-0" />
<p className="m-0 leading-none truncate">
{t("dashboard.totalCredentials")}
</p>
</div>
<p className="m-0 leading-none text-muted-foreground font-semibold">
{totalCredentials}
</p>
</div>
</div>
</div>
</div>
);
}
@@ -1,82 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { ChartLine, Loader2, Server } from "lucide-react";
import { Button } from "@/components/button";
interface ServerStat {
id: number;
name: string;
cpu: number | null;
ram: number | null;
}
interface ServerStatsCardProps {
serverStats: ServerStat[];
loading: boolean;
onServerClick: (serverId: number, serverName: string) => void;
}
export function ServerStatsCard({
serverStats,
loading,
onServerClick,
}: ServerStatsCardProps): React.ReactElement {
const { t } = useTranslation();
const visibleStats = serverStats.filter(
(s) => s.cpu !== null || s.ram !== null,
);
return (
<div className="border-2 border-edge rounded-md flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20 !bg-elevated">
<div className="flex flex-col mx-3 my-2 flex-1 overflow-hidden">
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
<ChartLine className="mr-3" />
{t("dashboard.serverStats")}
</p>
<div
className={`grid gap-4 grid-cols-3 auto-rows-min overflow-x-hidden thin-scrollbar ${loading ? "overflow-y-hidden" : "overflow-y-auto"}`}
>
{loading ? (
<div className="flex flex-row items-center text-muted-foreground text-sm animate-pulse">
<Loader2 className="animate-spin mr-2" size={16} />
<span>{t("dashboard.loadingServerStats")}</span>
</div>
) : visibleStats.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("dashboard.noServerData")}
</p>
) : (
visibleStats.map((server) => (
<Button
key={server.id}
variant="outline"
className="border-2 !border-edge h-auto p-3 min-w-0 !bg-canvas"
onClick={() => onServerClick(server.id, server.name)}
>
<div className="flex flex-col w-full">
<div className="flex flex-row items-center mb-2">
<Server size={20} className="shrink-0" />
<p className="truncate ml-2 font-semibold">{server.name}</p>
</div>
<div className="flex flex-row justify-start gap-4 text-xs text-muted-foreground">
{server.cpu !== null && (
<span>
{t("dashboard.cpu")}: {server.cpu.toFixed(1)}%
</span>
)}
{server.ram !== null && (
<span>
{t("dashboard.ram")}: {server.ram.toFixed(1)}%
</span>
)}
</div>
</div>
</Button>
))
)}
</div>
</div>
</div>
);
}
@@ -1,159 +0,0 @@
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import { Button } from "@/components/button";
import { Label } from "@/components/label";
import { Checkbox } from "@/components/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
import { useTranslation } from "react-i18next";
import type { DashboardLayout } from "@/main-axios";
interface DashboardSettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
currentLayout: DashboardLayout;
onSave: (layout: DashboardLayout) => void;
onReset: () => void;
}
export function DashboardSettingsDialog({
open,
onOpenChange,
currentLayout,
onSave,
onReset,
}: DashboardSettingsDialogProps): React.ReactElement {
const { t } = useTranslation();
const [layout, setLayout] = useState<DashboardLayout>(currentLayout);
useEffect(() => {
setLayout(currentLayout);
}, [currentLayout, open]);
const handleCardToggle = (cardId: string, enabled: boolean) => {
setLayout((prev) => ({
...prev,
cards: prev.cards.map((card) =>
card.id === cardId ? { ...card, enabled } : card,
),
}));
};
const handleCardPanel = (cardId: string, panel: "main" | "side") => {
setLayout((prev) => ({
...prev,
cards: prev.cards.map((card) =>
card.id === cardId ? { ...card, panel } : card,
),
}));
};
const handleSave = () => {
onSave(layout);
onOpenChange(false);
};
const handleReset = () => {
onReset();
onOpenChange(false);
};
const cardLabels: Record<string, string> = {
server_overview: t("dashboard.serverOverviewCard"),
recent_activity: t("dashboard.recentActivityCard"),
network_graph: t("dashboard.networkGraphCard"),
quick_actions: t("dashboard.quickActionsCard"),
server_stats: t("dashboard.serverStatsCard"),
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px] bg-canvas border-2 border-edge">
<DialogHeader>
<DialogTitle>{t("dashboard.dashboardSettings")}</DialogTitle>
<DialogDescription className="text-muted-foreground">
{t("dashboard.customizeLayout")}
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
<div className="space-y-3">
<Label className="text-base font-semibold">
{t("dashboard.enableDisableCards")}
</Label>
<div className="space-y-3">
{layout.cards?.map((card) => (
<div
key={card.id}
className="flex items-center space-x-3 border-2 border-edge rounded-md p-3"
>
<Checkbox
id={card.id}
checked={card.enabled}
onCheckedChange={(checked) =>
handleCardToggle(card.id, checked === true)
}
/>
<Label
htmlFor={card.id}
className="text-sm font-normal cursor-pointer flex-1"
>
{cardLabels[card.id] || card.id}
</Label>
<Select
value={card.panel ?? "main"}
onValueChange={(v) =>
handleCardPanel(card.id, v as "main" | "side")
}
>
<SelectTrigger className="w-24 h-7 text-xs border-edge">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="main">
{t("dashboard.panelMain")}
</SelectItem>
<SelectItem value="side">
{t("dashboard.panelSide")}
</SelectItem>
</SelectContent>
</Select>
</div>
))}
</div>
</div>
</div>
<DialogFooter className="flex-row gap-2">
<Button
variant="outline"
onClick={handleReset}
className="border-2 border-edge"
>
{t("dashboard.resetLayout")}
</Button>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="border-2 border-edge"
>
{t("common.cancel")}
</Button>
<Button onClick={handleSave}>{t("common.save")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,138 +0,0 @@
import { useState, useEffect, useCallback } from "react";
import {
getDashboardPreferences,
saveDashboardPreferences,
type DashboardLayout,
} from "@/main-axios";
const LS_KEY = "dashboardLayout";
const DEFAULT_LAYOUT: DashboardLayout = {
cards: [
{ id: "server_overview", enabled: true, order: 1, panel: "main" },
{ id: "quick_actions", enabled: true, order: 2, panel: "main" },
{ id: "server_stats", enabled: true, order: 3, panel: "main" },
{ id: "network_graph", enabled: false, order: 4, panel: "main" },
{ id: "recent_activity", enabled: true, order: 1, panel: "side" },
],
mainWidthPct: 68,
};
function migrateLayout(preferences: DashboardLayout): DashboardLayout {
const needsMigration = preferences.cards.some((c) => !c.panel);
if (!needsMigration) return preferences;
const defaultCardMap = new Map(DEFAULT_LAYOUT.cards.map((c) => [c.id, c]));
return {
...preferences,
mainWidthPct: preferences.mainWidthPct ?? DEFAULT_LAYOUT.mainWidthPct,
cards: preferences.cards.map((c) => ({
...c,
panel: c.panel ?? defaultCardMap.get(c.id)?.panel ?? "main",
})),
};
}
function readFromLocalStorage(): DashboardLayout | null {
try {
const raw = localStorage.getItem(LS_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (parsed?.cards && Array.isArray(parsed.cards)) {
return migrateLayout(parsed);
}
} catch {
// ignore
}
return null;
}
function writeToLocalStorage(layout: DashboardLayout) {
try {
localStorage.setItem(LS_KEY, JSON.stringify(layout));
} catch {
// ignore
}
}
export function useDashboardPreferences(enabled: boolean = true) {
const [layout, setLayout] = useState<DashboardLayout | null>(null);
const [loading, setLoading] = useState(true);
const [saveTimeout, setSaveTimeout] = useState<NodeJS.Timeout | null>(null);
useEffect(() => {
if (!enabled) {
setLayout(DEFAULT_LAYOUT);
setLoading(false);
return;
}
// Show cached layout immediately so the UI doesn't wait for the network
const cached = readFromLocalStorage();
if (cached) {
setLayout(cached);
setLoading(false);
}
const fetchPreferences = async () => {
try {
const preferences = await getDashboardPreferences();
if (preferences?.cards && Array.isArray(preferences.cards)) {
const migrated = migrateLayout(preferences);
setLayout(migrated);
writeToLocalStorage(migrated);
} else {
if (!cached) {
setLayout(DEFAULT_LAYOUT);
}
}
} catch {
if (!cached) {
setLayout(DEFAULT_LAYOUT);
}
} finally {
setLoading(false);
}
};
fetchPreferences();
}, [enabled]);
const updateLayout = useCallback(
(newLayout: DashboardLayout) => {
setLayout(newLayout);
writeToLocalStorage(newLayout);
if (saveTimeout) {
clearTimeout(saveTimeout);
}
const timeout = setTimeout(async () => {
try {
await saveDashboardPreferences(newLayout);
} catch (error) {
console.error("Failed to save dashboard preferences:", error);
}
}, 1000);
setSaveTimeout(timeout);
},
[saveTimeout],
);
const resetLayout = useCallback(async () => {
setLayout(DEFAULT_LAYOUT);
writeToLocalStorage(DEFAULT_LAYOUT);
try {
await saveDashboardPreferences(DEFAULT_LAYOUT);
} catch (error) {
console.error("Failed to reset dashboard preferences:", error);
}
}, []);
return {
layout,
loading,
updateLayout,
resetLayout,
};
}
+2 -5
View File
@@ -4,7 +4,7 @@ import { CommandHistoryProvider } from "@/features/terminal/command-history/Comm
import { SidebarProvider } from "@/components/sidebar.tsx";
import { getSSHHosts, getUserInfo } from "@/main-axios.ts";
import type { SSHHost } from "@/types";
import { Dashboard } from "@/dashboard/Dashboard.tsx";
import { LoginScreen } from "@/auth/LoginScreen.tsx";
import { Toaster } from "@/components/sonner.tsx";
import { dbHealthMonitor } from "@/lib/db-health-monitor.ts";
import { useTranslation } from "react-i18next";
@@ -110,12 +110,9 @@ export const FullScreenAppWrapper: React.FC<FullScreenAppWrapperProps> = ({
className="w-full h-screen overflow-hidden flex items-center justify-center"
style={{ backgroundColor: "var(--bg-base)" }}
>
<Dashboard
isAuthenticated={false}
<LoginScreen
authLoading={authLoading}
onAuthSuccess={handleAuthSuccess}
isTopbarOpen={false}
onSelectView={() => {}}
/>
<Toaster
position="bottom-right"
+16 -1
View File
@@ -5,7 +5,13 @@ import { Alert, AlertDescription } from "@/components/alert.tsx";
import { Button } from "@/components/button.tsx";
import { Card } from "@/components/card.tsx";
import { Input } from "@/components/input.tsx";
import { AlertCircle, Box, RefreshCw, Search } from "lucide-react";
import {
AlertCircle,
Box,
ExternalLink,
RefreshCw,
Search,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { SSHHost, DockerContainer, DockerValidation } from "@/types";
@@ -731,6 +737,15 @@ function DockerManagerInner({
className={`size-4 text-accent-brand ${isLoadingContainers ? "animate-spin" : ""}`}
/>
</Button>
<a
href="https://docs.termix.site/features/networking/docker"
target="_blank"
rel="noreferrer"
className="flex items-center justify-center size-9 text-muted-foreground hover:text-foreground transition-colors"
title={t("hosts.docsLink")}
>
<ExternalLink className="size-4" />
</a>
</div>
</Card>
@@ -3,6 +3,7 @@ import { useXTerm } from "react-xtermjs";
import { FitAddon } from "@xterm/addon-fit";
import { ClipboardAddon } from "@xterm/addon-clipboard";
import { RobustClipboardProvider } from "@/lib/clipboard-provider";
import { copyToClipboard } from "@/lib/clipboard";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { Button } from "@/components/button.tsx";
import {
@@ -20,11 +21,8 @@ import type { SSHHost } from "@/types";
import { isElectron } from "@/main-axios.ts";
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
import { useTranslation } from "react-i18next";
import {
TERMINAL_THEMES,
DEFAULT_TERMINAL_CONFIG,
TERMINAL_FONTS,
} from "@/lib/terminal-themes";
import { resolveTermixThemeColors } from "@/features/terminal/terminal-theme";
import { DEFAULT_TERMINAL_CONFIG, TERMINAL_FONTS } from "@/lib/terminal-themes";
import { useTheme } from "@/components/theme-provider";
interface ConsoleTerminalProps {
@@ -49,26 +47,10 @@ export function ConsoleTerminal({
[hostConfig.terminalConfig],
);
const isDarkMode =
appTheme === "dark" ||
appTheme === "dracula" ||
appTheme === "gentlemansChoice" ||
appTheme === "midnightEspresso" ||
appTheme === "catppuccinMocha" ||
(appTheme === "system" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
const themeColors = React.useMemo(() => {
const activeTheme = terminalConfig.theme;
if (activeTheme === "termix") {
return isDarkMode
? TERMINAL_THEMES.termixDark.colors
: TERMINAL_THEMES.termixLight.colors;
}
return (
TERMINAL_THEMES[activeTheme]?.colors ?? TERMINAL_THEMES.termixDark.colors
);
}, [terminalConfig.theme, isDarkMode]);
return resolveTermixThemeColors(activeTheme, appTheme);
}, [terminalConfig.theme, appTheme]);
const [isConnected, setIsConnected] = React.useState(false);
const [isConnecting, setIsConnecting] = React.useState(false);
@@ -108,15 +90,11 @@ export function ConsoleTerminal({
if (window.electronClipboard) {
return window.electronClipboard.readText();
}
return navigator.clipboard.readText();
return (await navigator.clipboard?.readText?.()) ?? "";
};
const writeTextToClipboard = async (text: string): Promise<void> => {
if (window.electronClipboard) {
await window.electronClipboard.writeText(text);
return;
}
await navigator.clipboard.writeText(text);
await copyToClipboard(text);
};
terminal.attachCustomKeyEventHandler((e: KeyboardEvent): boolean => {
+6 -7
View File
@@ -34,6 +34,7 @@ import {
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
import { ConnectionLog } from "@/ssh/connection-log/ConnectionLog.tsx";
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
import { copyToClipboard } from "@/lib/clipboard.ts";
import {
listSSHFiles,
resolveSSHPath,
@@ -1343,19 +1344,17 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
const paths = files.map((file) => file.path).join("\n");
navigator.clipboard.writeText(paths).then(
() => {
copyToClipboard(paths).then((ok) => {
if (ok) {
toast.success(
files.length === 1
? t("fileManager.pathCopiedToClipboard")
: t("fileManager.pathsCopiedToClipboard", { count: files.length }),
);
},
(err) => {
console.error("Failed to copy path to clipboard:", err);
} else {
toast.error(t("fileManager.failedToCopyPath"));
},
);
}
});
}
async function handlePasteFiles() {
@@ -192,6 +192,8 @@ export function FileManagerContextMenu({
adjustedX = viewportWidth - menuWidth - 10;
if (y + menuHeight > viewportHeight)
adjustedY = Math.max(10, viewportHeight - menuHeight - 10);
adjustedX = Math.max(8, adjustedX);
adjustedY = Math.max(8, adjustedY);
setMenuPosition({ x: adjustedX, y: adjustedY });
}, [isVisible, x, y, files.length]);
@@ -632,6 +632,18 @@ export function FileManagerGrid({
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Every open file-manager tab mounts its own grid with a global keydown
// listener, so without this guard a shortcut (notably Delete) would fire
// in every tab at once - including hidden ones - and act on their
// selections. Only the visible grid should respond. See issue #783.
const grid = gridRef.current;
if (!grid) return;
const isVisible =
typeof grid.checkVisibility === "function"
? grid.checkVisibility({ visibilityProperty: true })
: grid.offsetParent !== null;
if (!isVisible) return;
const activeElement = document.activeElement;
if (
activeElement &&
+55 -62
View File
@@ -3,8 +3,11 @@ import {
GuacamoleDisplay,
type GuacamoleDisplayHandle,
} from "@/features/guacamole/GuacamoleDisplay.tsx";
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
import { getGuacamoleTokenFromHost, getGuacdStatus } from "@/main-axios.ts";
import {
getGuacamoleTokenFromHost,
getGuacdStatus,
getSSHHosts,
} from "@/main-axios.ts";
import { useTranslation } from "react-i18next";
import { AlertCircle, RefreshCw } from "lucide-react";
import { GuacamoleToolbar } from "@/features/guacamole/GuacamoleToolbar.tsx";
@@ -24,68 +27,58 @@ const GuacamoleApp: React.FC<GuacamoleAppProps> = ({
protocol,
}) => {
const { t } = useTranslation();
const [hostConfig, setHostConfig] = useState<SSHHost | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!hostId) {
setLoading(false);
return;
}
getSSHHosts()
.then((hosts) => {
const host = hosts.find((h) => h.id === parseInt(hostId, 10));
setHostConfig(host ?? null);
})
.catch(() => setHostConfig(null))
.finally(() => setLoading(false));
}, [hostId]);
if (loading) {
return (
<div className="relative w-full h-full">
<SimpleLoader visible={true} message={t("common.loading")} />
</div>
);
}
if (!hostConfig || !hostId) {
return (
<div
className="flex flex-col items-center justify-center h-full gap-4"
style={{ backgroundColor: "var(--bg-base)" }}
>
<AlertCircle
className="size-10"
style={{ color: "var(--foreground)" }}
/>
<span
className="text-sm font-semibold"
style={{ color: "var(--foreground)" }}
>
{t("guacamole.hostNotFound")}
</span>
</div>
);
}
return (
<FullScreenAppWrapper hostId={hostId}>
{(hostConfig, loading) => {
if (loading) {
return (
<div className="relative w-full h-full">
<SimpleLoader visible={true} message={t("common.loading")} />
</div>
);
}
if (!hostConfig) {
return (
<div
className="flex flex-col items-center justify-center h-full gap-4"
style={{ backgroundColor: "var(--bg-base)" }}
>
<AlertCircle
className="size-10"
style={{ color: "var(--foreground)" }}
/>
<span
className="text-sm font-semibold"
style={{ color: "var(--foreground)" }}
>
{t("guacamole.hostNotFound")}
</span>
</div>
);
}
if (!hostId) {
return (
<div
className="flex flex-col items-center justify-center h-full gap-4"
style={{ backgroundColor: "var(--bg-base)" }}
>
<AlertCircle
className="size-10"
style={{ color: "var(--foreground)" }}
/>
<span
className="text-sm font-semibold"
style={{ color: "var(--foreground)" }}
>
{t("guacamole.hostNotFound")}
</span>
</div>
);
}
return (
<GuacamoleAppInner
hostId={parseInt(hostId, 10)}
hostConfig={hostConfig}
tabId={tabId}
protocol={protocol}
/>
);
}}
</FullScreenAppWrapper>
<GuacamoleAppInner
hostId={parseInt(hostId, 10)}
hostConfig={hostConfig}
tabId={tabId}
protocol={protocol}
/>
);
};
+21 -3
View File
@@ -275,9 +275,27 @@ export const GuacamoleDisplay = forwardRef<
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
const rect = containerRef.current?.getBoundingClientRect();
let containerWidth = rect?.width || 0;
let containerHeight = rect?.height || 0;
// The tab's DOM node can still be display:none (and report 0x0) when this
// tab is restored in the background. Measuring then would force the
// window-size fallback, which ignores the tab bar and makes the remote
// resolution too tall (the bottom gets cut off). Poll briefly for a real
// size before connecting so we capture the actual visible viewport.
const measureContainer = () => {
const rect = containerRef.current?.getBoundingClientRect();
return { width: rect?.width || 0, height: rect?.height || 0 };
};
let { width: containerWidth, height: containerHeight } = measureContainer();
for (
let attempt = 0;
(containerWidth < 100 || containerHeight < 100) && attempt < 40;
attempt++
) {
await new Promise<void>((resolve) =>
requestAnimationFrame(() => resolve()),
);
({ width: containerWidth, height: containerHeight } = measureContainer());
}
if (containerWidth < 100 || containerHeight < 100) {
containerWidth = window.innerWidth || 1280;
@@ -1,22 +1,22 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { ServerStats } from "@/features/server-stats/ServerStats.tsx";
import { HostMetricsTab } from "@/features/host-metrics/HostMetricsTab.tsx";
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
interface ServerStatsAppProps {
interface HostMetricsAppProps {
hostId?: string;
}
const ServerStatsApp: React.FC<ServerStatsAppProps> = ({ hostId }) => {
const HostMetricsApp: React.FC<HostMetricsAppProps> = ({ hostId }) => {
const { t } = useTranslation();
return (
<FullScreenAppWrapper hostId={hostId}>
{(hostConfig, loading) => {
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<div className="flex h-full items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
<div className="mx-auto mb-2 h-8 w-8 animate-spin rounded-full border-b-2 border-white"></div>
<p className="text-muted-foreground">
{t("hosts.loadingHost")}
</p>
@@ -27,16 +27,16 @@ const ServerStatsApp: React.FC<ServerStatsAppProps> = ({ hostId }) => {
if (!hostConfig) {
return (
<div className="flex items-center justify-center h-full">
<div className="flex h-full items-center justify-center">
<div className="text-center">
<p className="text-red-500 mb-4">{t("hosts.hostNotFound")}</p>
<p className="mb-4 text-red-500">{t("hosts.hostNotFound")}</p>
</div>
</div>
);
}
return (
<ServerStats
<HostMetricsTab
hostConfig={hostConfig}
title={hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`}
isVisible={true}
@@ -49,4 +49,4 @@ const ServerStatsApp: React.FC<ServerStatsAppProps> = ({ hostId }) => {
);
};
export default ServerStatsApp;
export default HostMetricsApp;
@@ -19,30 +19,37 @@ import { useTabsSafe } from "@/shell/TabContext.tsx";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
type WidgetType,
type StatsConfig,
DEFAULT_STATS_CONFIG,
} from "@/types/stats-widgets.ts";
import {
CpuWidget,
MemoryWidget,
DiskWidget,
NetworkWidget,
UptimeWidget,
ProcessesWidget,
SystemWidget,
LoginStatsWidget,
PortsWidget,
FirewallWidget,
} from "./widgets";
defaultLayoutFromWidgets,
type HostMetricsLayout,
} from "@/types/host-metrics.ts";
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
import { RefreshCw, Server } from "lucide-react";
import { RefreshCw, Server, LayoutDashboard } from "lucide-react";
import {
ConnectionLogProvider,
useConnectionLog,
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
import { ConnectionLog } from "@/ssh/connection-log/ConnectionLog.tsx";
import type { LogEntry } from "@/types/connection-log.ts";
import {
CardGridCanvas,
ColumnCountStepper,
} from "@/components/card-grid/CardGridCanvas.tsx";
import type { GridCardCatalogEntry } from "@/components/card-grid/types.ts";
import { useHostMetricsPreferences } from "./hooks/useHostMetricsPreferences.ts";
import {
CARD_DEFINITIONS,
IMPLEMENTED_CARD_IDS,
getCardDefinition,
defaultColSpanFor,
defaultHeightFor,
type MetricCardHistories,
} from "./cards";
const HISTORY_LEN = 30;
interface QuickAction {
name: string;
@@ -50,7 +57,6 @@ interface QuickAction {
}
type ConnectionLogPayload = Omit<LogEntry, "id" | "timestamp">;
type ConnectionLogError = Error & {
connectionLogs?: ConnectionLogPayload[];
};
@@ -60,15 +66,14 @@ interface HostConfig {
name: string;
ip: string;
username: string;
folder?: string;
enableFileManager?: boolean;
tunnelConnections?: unknown[];
quickActions?: QuickAction[];
statsConfig?: string | StatsConfig;
authType?: string;
port?: number;
[key: string]: unknown;
}
interface ServerProps {
interface HostMetricsProps {
hostConfig?: HostConfig;
title?: string;
isVisible?: boolean;
@@ -76,13 +81,23 @@ interface ServerProps {
embedded?: boolean;
}
function ServerStatsInner({
function parseStatsConfig(raw?: string | StatsConfig): StatsConfig {
if (!raw) return DEFAULT_STATS_CONFIG;
try {
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
return { ...DEFAULT_STATS_CONFIG, ...parsed };
} catch {
return DEFAULT_STATS_CONFIG;
}
}
function HostMetricsInner({
hostConfig,
title,
isVisible = true,
isTopbarOpen = true,
embedded = false,
}: ServerProps): React.ReactElement {
}: HostMetricsProps): React.ReactElement {
const { t } = useTranslation();
const {
addLog,
@@ -90,17 +105,19 @@ function ServerStatsInner({
isExpanded: isConnectionLogExpanded,
} = useConnectionLog();
const { currentTab, removeTab } = useTabsSafe();
const [serverStatus, setServerStatus] = React.useState<"online" | "offline">(
"offline",
);
const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null);
const [metricsHistory, setMetricsHistory] = React.useState<ServerMetrics[]>(
[],
);
const [histories, setHistories] = React.useState<MetricCardHistories>({
cpu: [],
memory: [],
disk: [],
});
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
const [isLoadingMetrics, setIsLoadingMetrics] = React.useState(false);
const [isRefreshing, setIsRefreshing] = React.useState(false);
const [showStatsUI, setShowStatsUI] = React.useState(true);
const [executingActions, setExecutingActions] = React.useState<Set<number>>(
new Set(),
);
@@ -113,61 +130,81 @@ function ServerStatsInner({
null,
);
const [hasConnectionError, setHasConnectionError] = React.useState(false);
const [editMode, setEditMode] = React.useState(false);
const activityLoggedRef = React.useRef(false);
const activityLoggingRef = React.useRef(false);
const statsConfig = React.useMemo((): StatsConfig => {
if (!currentHostConfig?.statsConfig) {
return DEFAULT_STATS_CONFIG;
}
try {
const parsed =
typeof currentHostConfig.statsConfig === "string"
? JSON.parse(currentHostConfig.statsConfig)
: currentHostConfig.statsConfig;
return { ...DEFAULT_STATS_CONFIG, ...parsed };
} catch (error) {
console.error("Failed to parse statsConfig:", error);
return DEFAULT_STATS_CONFIG;
}
}, [currentHostConfig?.statsConfig]);
const enabledWidgets = statsConfig.enabledWidgets;
const statusCheckEnabled = statsConfig.statusCheckEnabled !== false;
const statsConfig = React.useMemo(
() => parseStatsConfig(currentHostConfig?.statsConfig),
[currentHostConfig?.statsConfig],
);
const metricsEnabled = statsConfig.metricsEnabled !== false;
const statusCheckEnabled = statsConfig.statusCheckEnabled !== false;
const hostId = currentHostConfig?.id ?? null;
const { layout, setLayout } = useHostMetricsPreferences(hostId);
const effectiveLayout: HostMetricsLayout = React.useMemo(() => {
if (layout) return layout;
return defaultLayoutFromWidgets(statsConfig.enabledWidgets ?? []);
}, [layout, statsConfig.enabledWidgets]);
// Only render/keep cards that are implemented (metric cards in Phase A).
const visibleSlots = React.useMemo(
() => effectiveLayout.slots.filter((s) => getCardDefinition(s.id)),
[effectiveLayout.slots],
);
const cardCatalog: GridCardCatalogEntry[] = React.useMemo(
() =>
IMPLEMENTED_CARD_IDS.map((id) => ({
id,
label: t(CARD_DEFINITIONS[id].labelKey),
defaultColSpan: defaultColSpanFor(id),
defaultHeight: defaultHeightFor(id),
})),
[t],
);
const cardLabel = React.useCallback(
(id: string) => {
const def = getCardDefinition(id);
return def ? t(def.labelKey) : id;
},
[t],
);
const renderCard = React.useCallback(
(id: string) => {
const def = getCardDefinition(id);
if (!def) return null;
return def.render({ metrics, histories, hostId });
},
[metrics, histories, hostId],
);
React.useEffect(() => {
const handleVisibilityChange = () => {
setIsPageVisible(!document.hidden);
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () =>
document.removeEventListener("visibilitychange", handleVisibilityChange);
const onVis = () => setIsPageVisible(!document.hidden);
document.addEventListener("visibilitychange", onVis);
return () => document.removeEventListener("visibilitychange", onVis);
}, []);
const isActuallyVisible = isVisible && isPageVisible;
React.useEffect(() => {
if (!viewerSessionId || !isActuallyVisible) return;
const heartbeatInterval = setInterval(async () => {
try {
await sendMetricsHeartbeat(viewerSessionId);
} catch (error) {
console.error("Failed to send heartbeat:", error);
}
const interval = setInterval(() => {
sendMetricsHeartbeat(viewerSessionId).catch(() => {});
}, 30000);
return () => clearInterval(heartbeatInterval);
return () => clearInterval(interval);
}, [viewerSessionId, isActuallyVisible]);
React.useEffect(() => {
if (hostConfig?.id !== currentHostConfig?.id) {
setServerStatus("offline");
setMetrics(null);
setMetricsHistory([]);
setShowStatsUI(true);
setHistories({ cpu: [], memory: [], disk: [] });
}
setCurrentHostConfig(hostConfig);
}, [hostConfig?.id]);
@@ -180,146 +217,72 @@ function ServerStatsInner({
) {
return;
}
activityLoggingRef.current = true;
activityLoggedRef.current = true;
try {
const hostName =
currentHostConfig.name ||
`${currentHostConfig.username}@${currentHostConfig.ip}`;
await logActivity("server_stats", currentHostConfig.id, hostName);
} catch (err) {
console.warn("Failed to log server stats activity:", err);
} catch {
activityLoggedRef.current = false;
} finally {
activityLoggingRef.current = false;
}
};
const pushHistory = React.useCallback((data: ServerMetrics) => {
setHistories((prev) => {
const add = (arr: number[], v: number | null | undefined) =>
[...arr, v ?? 0].slice(-HISTORY_LEN);
return {
cpu: add(prev.cpu, data.cpu?.percent),
memory: add(prev.memory, data.memory?.percent),
disk: add(prev.disk, data.disk?.percent),
};
});
}, []);
const handleTOTPSubmit = async (totpCode: string) => {
if (!totpSessionId || !currentHostConfig) return;
try {
const result = await submitMetricsTOTP(totpSessionId, totpCode);
if (result.success) {
setTotpRequired(false);
setTotpSessionId(null);
setShowStatsUI(true);
setTotpVerified(true);
if (result.viewerSessionId) {
setViewerSessionId(result.viewerSessionId);
}
if (result.viewerSessionId) setViewerSessionId(result.viewerSessionId);
} else {
toast.error(t("serverStats.totpFailed"));
toast.error(t("hostMetrics.totpFailed"));
}
} catch (error) {
toast.error(t("serverStats.totpFailed"));
console.error("TOTP verification failed:", error);
} catch {
toast.error(t("hostMetrics.totpFailed"));
}
};
const handleTOTPCancel = async () => {
setTotpRequired(false);
if (currentHostConfig?.id) {
try {
await stopMetricsPolling(currentHostConfig.id);
} catch (error) {
console.error("Failed to stop metrics polling:", error);
}
}
if (currentTab !== null) {
removeTab(currentTab);
}
};
const renderWidget = (widgetType: WidgetType) => {
switch (widgetType) {
case "cpu":
return <CpuWidget metrics={metrics} metricsHistory={metricsHistory} />;
case "memory":
return (
<MemoryWidget metrics={metrics} metricsHistory={metricsHistory} />
);
case "disk":
return <DiskWidget metrics={metrics} metricsHistory={metricsHistory} />;
case "network":
return (
<NetworkWidget metrics={metrics} metricsHistory={metricsHistory} />
);
case "uptime":
return (
<UptimeWidget metrics={metrics} metricsHistory={metricsHistory} />
);
case "processes":
return (
<ProcessesWidget metrics={metrics} metricsHistory={metricsHistory} />
);
case "system":
return (
<SystemWidget metrics={metrics} metricsHistory={metricsHistory} />
);
case "login_stats":
return (
<LoginStatsWidget metrics={metrics} metricsHistory={metricsHistory} />
);
case "ports":
return (
<PortsWidget metrics={metrics} metricsHistory={metricsHistory} />
);
case "firewall":
return (
<FirewallWidget metrics={metrics} metricsHistory={metricsHistory} />
);
default:
return null;
await stopMetricsPolling(currentHostConfig.id).catch(() => {});
}
if (currentTab !== null) removeTab(currentTab);
};
React.useEffect(() => {
const fetchLatestHostConfig = async () => {
if (hostConfig?.id) {
try {
const hosts = await getSSHHosts();
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
if (updatedHost) {
setCurrentHostConfig(updatedHost);
}
} catch {
toast.error(t("serverStats.failedToFetchHostConfig"));
}
const fetchLatest = async () => {
if (!hostConfig?.id) return;
try {
const hosts = await getSSHHosts();
const updated = hosts.find((h) => h.id === hostConfig.id);
if (updated) setCurrentHostConfig(updated as unknown as HostConfig);
} catch {
toast.error(t("hostMetrics.failedToFetchHostConfig"));
}
};
fetchLatestHostConfig();
const handleHostsChanged = async () => {
if (hostConfig?.id) {
try {
const hosts = await getSSHHosts();
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
if (updatedHost) {
setCurrentHostConfig(updatedHost);
}
} catch {
toast.error(t("serverStats.failedToFetchHostConfig"));
}
}
};
window.addEventListener("ssh-hosts:changed", handleHostsChanged);
return () =>
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
fetchLatest();
const onChanged = () => fetchLatest();
window.addEventListener("ssh-hosts:changed", onChanged);
return () => window.removeEventListener("ssh-hosts:changed", onChanged);
}, [hostConfig?.id]);
React.useEffect(() => {
@@ -327,41 +290,25 @@ function ServerStatsInner({
setServerStatus("offline");
return;
}
let cancelled = false;
const fetchStatus = async () => {
try {
const res = await getServerStatusById(currentHostConfig?.id);
const res = await getServerStatusById(currentHostConfig.id);
if (!cancelled) {
setServerStatus(res?.status === "online" ? "online" : "offline");
}
} catch (error: unknown) {
if (!cancelled) {
const err = error as {
response?: { status?: number };
};
if (err?.response?.status === 503) {
setServerStatus("offline");
} else if (err?.response?.status === 504) {
setServerStatus("offline");
} else if (err?.response?.status === 404) {
setServerStatus("offline");
} else {
setServerStatus("offline");
}
}
} catch {
if (!cancelled) setServerStatus("offline");
}
};
fetchStatus();
const intervalId = window.setInterval(
const id = window.setInterval(
fetchStatus,
statsConfig.statusCheckInterval * 1000,
);
return () => {
cancelled = true;
window.clearInterval(intervalId);
window.clearInterval(id);
};
}, [
currentHostConfig?.id,
@@ -370,37 +317,24 @@ function ServerStatsInner({
]);
React.useEffect(() => {
if (!metricsEnabled || !currentHostConfig?.id) {
return;
}
if (!metricsEnabled || !currentHostConfig?.id) return;
let cancelled = false;
let pollingIntervalId: number | undefined;
if (isActuallyVisible && !metrics) {
setIsLoadingMetrics(true);
setShowStatsUI(true);
} else if (!isActuallyVisible) {
setIsLoadingMetrics(false);
}
let pollingId: number | undefined;
if (isActuallyVisible && !metrics) setIsLoadingMetrics(true);
else if (!isActuallyVisible) setIsLoadingMetrics(false);
const startMetrics = async () => {
if (cancelled) return;
if (currentHostConfig.authType === "none") {
toast.error(t("serverStats.noneAuthNotSupported"));
toast.error(t("hostMetrics.noneAuthNotSupported"));
setIsLoadingMetrics(false);
if (currentTab !== null) {
removeTab(currentTab);
}
if (currentTab !== null) removeTab(currentTab);
return;
}
const hasExistingMetrics = metrics !== null;
if (!hasExistingMetrics) {
setIsLoadingMetrics(true);
}
setShowStatsUI(true);
const hadMetrics = metrics !== null;
if (!hadMetrics) setIsLoadingMetrics(true);
setHasConnectionError(false);
clearLogs();
@@ -412,20 +346,10 @@ function ServerStatsInner({
message: `Connecting to ${currentHostConfig.username}@${currentHostConfig.ip}:${currentHostConfig.port}`,
});
const result = await startMetricsPolling(currentHostConfig.id);
if (cancelled) return;
if (result?.connectionLogs) {
result.connectionLogs.forEach((log) => {
addLog({
type: log.type,
stage: log.stage,
message: log.message,
details: log.details,
});
});
}
result?.connectionLogs?.forEach((log) =>
addLog(log as ConnectionLogPayload),
);
if (result.requires_totp) {
setTotpRequired(true);
setTotpSessionId(result.sessionId || null);
@@ -433,114 +357,89 @@ function ServerStatsInner({
setIsLoadingMetrics(false);
return;
}
if (result.viewerSessionId) {
if (result.viewerSessionId)
setViewerSessionId(result.viewerSessionId);
}
}
let retryCount = 0;
let data = null;
let retry = 0;
let data: ServerMetrics | null = null;
const maxRetries = 15;
const retryDelay = 2000;
while (retryCount < maxRetries && !cancelled) {
while (retry < maxRetries && !cancelled) {
try {
data = await getServerMetricsById(currentHostConfig.id);
break;
} catch (error: unknown) {
retryCount++;
if (retryCount === 1) {
const initialDelay = totpVerified ? 3000 : 5000;
await new Promise((resolve) => setTimeout(resolve, initialDelay));
} else if (retryCount < maxRetries && !cancelled) {
await new Promise((resolve) => setTimeout(resolve, retryDelay));
} catch (error) {
retry++;
if (retry === 1) {
await new Promise((r) =>
setTimeout(r, totpVerified ? 3000 : 5000),
);
} else if (retry < maxRetries && !cancelled) {
await new Promise((r) => setTimeout(r, 2000));
} else {
throw error;
}
}
}
if (cancelled) return;
if (data) {
setMetrics(data);
setServerStatus("online");
if (!hasExistingMetrics) {
if (!hadMetrics) {
setIsLoadingMetrics(false);
logServerActivity();
}
}
pollingIntervalId = window.setInterval(async () => {
pollingId = window.setInterval(async () => {
if (cancelled) return;
try {
const data = await getServerMetricsById(currentHostConfig.id);
if (!cancelled && data) {
setMetrics(data);
setMetricsHistory((prev) => {
const newHistory = [...prev, data];
return newHistory.slice(-20);
});
}
} catch (error) {
if (!cancelled) {
console.error("Failed to fetch metrics:", error);
const next = await getServerMetricsById(currentHostConfig.id);
if (!cancelled && next) {
setMetrics(next);
pushHistory(next);
}
} catch {
/* keep prior */
}
}, statsConfig.metricsInterval * 1000);
} catch (error: unknown) {
if (!cancelled) {
const logError = error as ConnectionLogError;
console.error("Failed to start metrics polling:", error);
setIsLoadingMetrics(false);
setHasConnectionError(true);
if (logError.connectionLogs) {
logError.connectionLogs.forEach((log) => {
addLog({
type: log.type,
stage: log.stage,
message: log.message,
details: log.details,
});
});
} else {
addLog({
type: "error",
stage: "connection",
message:
error instanceof Error
? error.message
: t("serverStats.connectionFailed"),
});
}
if (cancelled) return;
const logError = error as ConnectionLogError;
setIsLoadingMetrics(false);
setHasConnectionError(true);
if (logError.connectionLogs) {
logError.connectionLogs.forEach((log) => addLog(log));
} else {
addLog({
type: "error",
stage: "connection",
message:
error instanceof Error
? error.message
: t("hostMetrics.connectionFailed"),
});
}
}
};
const stopMetrics = async () => {
if (pollingIntervalId) {
window.clearInterval(pollingIntervalId);
pollingIntervalId = undefined;
if (pollingId) {
window.clearInterval(pollingId);
pollingId = undefined;
}
if (currentHostConfig?.id) {
try {
await stopMetricsPolling(
currentHostConfig.id,
viewerSessionId || undefined,
);
} catch (error) {
console.error("Failed to stop metrics polling:", error);
}
await stopMetricsPolling(
currentHostConfig.id,
viewerSessionId || undefined,
).catch(() => {});
}
};
const debounceTimeout = setTimeout(() => {
const debounce = setTimeout(() => {
if (isActuallyVisible) {
if (!hasConnectionError) {
startMetrics();
}
if (!hasConnectionError) startMetrics();
} else {
stopMetrics();
}
@@ -548,8 +447,8 @@ function ServerStatsInner({
return () => {
cancelled = true;
clearTimeout(debounceTimeout);
if (pollingIntervalId) window.clearInterval(pollingIntervalId);
clearTimeout(debounce);
if (pollingId) window.clearInterval(pollingId);
if (currentHostConfig?.id) {
stopMetricsPolling(currentHostConfig.id).catch(() => {});
}
@@ -573,13 +472,11 @@ function ServerStatsInner({
const handleRefresh = async () => {
if (!currentHostConfig?.id) return;
if (hasConnectionError) {
setHasConnectionError(false);
clearLogs();
return;
}
try {
setIsRefreshing(true);
const res = await getServerStatusById(currentHostConfig.id);
@@ -587,7 +484,7 @@ function ServerStatsInner({
const data = await getServerMetricsById(currentHostConfig.id);
if (data) {
setMetrics(data);
setShowStatsUI(true);
pushHistory(data);
}
} catch {
setServerStatus("offline");
@@ -596,13 +493,22 @@ function ServerStatsInner({
}
};
const showCards =
metricsEnabled && !isLoadingMetrics && metrics && !hasConnectionError;
const showOffline =
metricsEnabled &&
!isLoadingMetrics &&
!metrics &&
serverStatus === "offline" &&
!hasConnectionError;
return (
<div
style={wrapperStyle}
className="relative overflow-hidden flex flex-col"
className="relative flex flex-col overflow-hidden"
>
<div
className="flex flex-col flex-1 min-h-0 overflow-hidden"
className="flex min-h-0 flex-1 flex-col overflow-hidden"
style={{
visibility:
hasConnectionError && isConnectionLogExpanded
@@ -610,22 +516,20 @@ function ServerStatsInner({
: "visible",
}}
>
<div className="flex-1 overflow-y-auto overflow-x-hidden flex flex-col">
<div className="flex flex-1 flex-col overflow-x-hidden overflow-y-auto">
{!totpRequired && !isLoadingMetrics && !hasConnectionError && (
<div className="mx-3 mt-3 flex items-center justify-between border border-border bg-card px-3 py-3 shrink-0">
<div className="mx-3 mt-3 flex shrink-0 items-center justify-between border border-border bg-card px-3 py-3">
<div className="flex items-center gap-3">
<div className="size-10 border border-border bg-muted flex items-center justify-center shrink-0">
<div className="flex size-10 shrink-0 items-center justify-center border border-border bg-muted">
<Server className="size-5 text-accent-brand" />
</div>
<div>
<h1 className="text-lg md:text-2xl font-bold">{title}</h1>
</div>
<h1 className="text-lg font-bold md:text-2xl">{title}</h1>
</div>
<div className="flex items-center gap-0">
{currentHostConfig?.quickActions &&
currentHostConfig.quickActions.length > 0 && (
<>
<div className="flex flex-wrap gap-2 mr-3">
<div className="mr-3 flex flex-wrap gap-2">
{currentHostConfig.quickActions.map((action, index) => {
const isExecuting = executingActions.has(
action.snippetId,
@@ -643,7 +547,7 @@ function ServerStatsInner({
new Set(prev).add(action.snippetId),
);
toast.loading(
t("serverStats.executingQuickAction", {
t("hostMetrics.executingQuickAction", {
name: action.name,
}),
{ id: `quick-action-${action.snippetId}` },
@@ -655,7 +559,7 @@ function ServerStatsInner({
);
if (result.success) {
toast.success(
t("serverStats.quickActionSuccess", {
t("hostMetrics.quickActionSuccess", {
name: action.name,
}),
{
@@ -669,7 +573,7 @@ function ServerStatsInner({
);
} else {
toast.error(
t("serverStats.quickActionFailed", {
t("hostMetrics.quickActionFailed", {
name: action.name,
}),
{
@@ -682,7 +586,7 @@ function ServerStatsInner({
}
} catch (error) {
toast.error(
t("serverStats.quickActionError", {
t("hostMetrics.quickActionError", {
name: action.name,
}),
{
@@ -705,7 +609,7 @@ function ServerStatsInner({
>
{isExecuting ? (
<>
<RefreshCw className="size-3 animate-spin mr-1" />
<RefreshCw className="mr-1 size-3 animate-spin" />
{action.name}
</>
) : (
@@ -715,53 +619,97 @@ function ServerStatsInner({
);
})}
</div>
<Separator orientation="vertical" className="h-8 mx-3" />
<Separator orientation="vertical" className="mx-3 h-8" />
</>
)}
{editMode && (
<>
<ColumnCountStepper
columns={effectiveLayout.columns}
onChange={(columns) =>
setLayout({ ...effectiveLayout, columns })
}
/>
<Button
variant="ghost"
size="sm"
className="ml-2 text-xs text-muted-foreground"
onClick={() =>
setLayout(
defaultLayoutFromWidgets(
statsConfig.enabledWidgets ?? [],
),
)
}
>
{t("hostMetrics.reset")}
</Button>
</>
)}
<Button
variant={editMode ? "default" : "ghost"}
size="icon"
className="ml-1"
title={t("hostMetrics.customize")}
onClick={() => setEditMode((v) => !v)}
>
<LayoutDashboard className="size-4" />
</Button>
<Button
variant="outline"
size="default"
onClick={handleRefresh}
disabled={isRefreshing}
className="gap-2 font-semibold"
className="ml-1 gap-2 font-semibold"
>
<RefreshCw
className={`size-3.5 ${isRefreshing ? "animate-spin" : ""}`}
/>
{t("serverStats.refresh")}
{t("hostMetrics.refresh")}
</Button>
</div>
</div>
)}
{metricsEnabled &&
showStatsUI &&
!isLoadingMetrics &&
!metrics &&
serverStatus === "offline" &&
!hasConnectionError && (
<div className="flex-1 flex items-center justify-center py-20">
<div className="text-center">
<div className="opacity-40">
<Server className="size-16 mx-auto mb-4" />
<p className="text-xl font-bold uppercase tracking-widest">
{t("serverStats.serverOffline")}
</p>
<p className="text-sm font-semibold">
{t("serverStats.cannotFetchMetrics")}
</p>
</div>
</div>
</div>
)}
{editMode && (
<div className="mx-3 mt-3 flex shrink-0 items-center gap-2 border border-dashed border-accent-brand/40 bg-accent-brand/5 px-4 py-2">
<LayoutDashboard className="size-3.5 shrink-0 text-accent-brand" />
<span className="text-xs font-semibold text-accent-brand">
{t("hostMetrics.editModeInstructions")}
</span>
</div>
)}
{metricsEnabled && showStatsUI && !isLoadingMetrics && metrics && (
<div className="px-3 pt-3 pb-3 columns-1 md:columns-2 lg:columns-3 gap-3">
{enabledWidgets.map((widgetType) => (
<div key={widgetType} className="break-inside-avoid mb-3">
{renderWidget(widgetType)}
</div>
))}
{showOffline && (
<div className="flex flex-1 items-center justify-center py-20">
<div className="text-center opacity-40">
<Server className="mx-auto mb-4 size-16" />
<p className="text-xl font-bold uppercase tracking-widest">
{t("hostMetrics.serverOffline")}
</p>
<p className="text-sm font-semibold">
{t("hostMetrics.cannotFetchMetrics")}
</p>
</div>
</div>
)}
{showCards && (
<div className="px-3 pt-3 pb-3">
<CardGridCanvas
slots={visibleSlots}
columns={effectiveLayout.columns}
editMode={editMode}
renderCard={renderCard}
cardCatalog={cardCatalog}
cardLabel={cardLabel}
onChange={(slots, columns) =>
setLayout({
slots: slots as HostMetricsLayout["slots"],
columns,
})
}
/>
</div>
)}
</div>
@@ -769,7 +717,7 @@ function ServerStatsInner({
{metricsEnabled && (
<SimpleLoader
visible={isLoadingMetrics && !metrics && !isConnectionLogExpanded}
message={t("serverStats.connecting")}
message={t("hostMetrics.connecting")}
/>
)}
</div>
@@ -791,10 +739,10 @@ function ServerStatsInner({
);
}
export function ServerStats(props: ServerProps): React.ReactElement {
export function HostMetricsTab(props: HostMetricsProps): React.ReactElement {
return (
<ConnectionLogProvider>
<ServerStatsInner {...props} />
<HostMetricsInner {...props} />
</ConnectionLogProvider>
);
}
@@ -0,0 +1,52 @@
import { Cpu } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/main-axios";
import { RadialGauge, Sparkline, MiniStat } from "@/components/charts";
import { MetricCard } from "./MetricCard";
export function CpuCard({
metrics,
history,
}: {
metrics: ServerMetrics | null;
history: number[];
}) {
const { t } = useTranslation();
const percent = metrics?.cpu?.percent ?? null;
const cores = metrics?.cpu?.cores ?? null;
const load = metrics?.cpu?.load ?? null;
return (
<MetricCard
title={t("hostMetrics.cpuUsage")}
icon={<Cpu className="size-3.5" />}
>
<div className="flex h-full flex-col gap-3">
<div className="flex items-center gap-4">
<RadialGauge
value={percent}
caption={
cores !== null
? t("hostMetrics.cpuCores", { count: cores })
: undefined
}
/>
<div className="flex min-w-0 flex-1 flex-col gap-2">
{load && (
<div className="grid grid-cols-3 gap-2">
<MiniStat caption="1m" value={load[0].toFixed(2)} />
<MiniStat caption="5m" value={load[1].toFixed(2)} />
<MiniStat caption="15m" value={load[2].toFixed(2)} />
</div>
)}
<Sparkline
data={[...history, percent ?? 0]}
domain={[0, 100]}
height={48}
/>
</div>
</div>
</div>
</MetricCard>
);
}
@@ -0,0 +1,51 @@
import { HardDrive } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/main-axios";
import { RadialGauge, Sparkline, MiniStat } from "@/components/charts";
import { MetricCard } from "./MetricCard";
export function DiskCard({
metrics,
history,
}: {
metrics: ServerMetrics | null;
history: number[];
}) {
const { t } = useTranslation();
const percent = metrics?.disk?.percent ?? null;
const usedHuman = metrics?.disk?.usedHuman ?? null;
const totalHuman = metrics?.disk?.totalHuman ?? null;
const availableHuman = metrics?.disk?.availableHuman ?? null;
return (
<MetricCard
title={t("hostMetrics.diskUsage")}
icon={<HardDrive className="size-3.5" />}
>
<div className="flex h-full flex-col gap-3">
<div className="flex items-center gap-4">
<RadialGauge value={percent} />
<div className="flex min-w-0 flex-1 flex-col gap-2">
<div className="grid grid-cols-2 gap-2">
<MiniStat
caption={t("hostMetrics.disk")}
value={
usedHuman && totalHuman ? `${usedHuman}/${totalHuman}` : "N/A"
}
/>
<MiniStat
caption={t("hostMetrics.available")}
value={availableHuman ?? "N/A"}
/>
</div>
<Sparkline
data={[...history, percent ?? 0]}
domain={[0, 100]}
height={48}
/>
</div>
</div>
</div>
</MetricCard>
);
}
@@ -0,0 +1,56 @@
import { ShieldCheck } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/main-axios";
import { MetricCard } from "./MetricCard";
export function FirewallCard({ metrics }: { metrics: ServerMetrics | null }) {
const { t } = useTranslation();
const fw = metrics?.firewall;
const totalRules =
fw?.chains?.reduce((sum, c) => sum + (c.rules?.length ?? 0), 0) ?? 0;
return (
<MetricCard
title={t("hostMetrics.firewall.title")}
icon={<ShieldCheck className="size-3.5" />}
>
{!fw || fw.type === "none" ? (
<span className="text-xs text-muted-foreground">
{t("hostMetrics.firewall.noData")}
</span>
) : (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span
className={`text-xs font-semibold uppercase tracking-wide ${
fw.status === "active"
? "text-accent-brand"
: "text-muted-foreground"
}`}
>
{fw.status === "inactive"
? t("hostMetrics.firewall.inactive")
: fw.status}
</span>
<span className="font-mono text-xs text-muted-foreground">
{fw.type}
</span>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span>
<span className="font-bold text-foreground">
{fw.chains?.length ?? 0}
</span>{" "}
{t("hostMetrics.firewall.chains")}
</span>
<span>
<span className="font-bold text-foreground">{totalRules}</span>{" "}
{t("hostMetrics.firewall.rules")}
</span>
</div>
</div>
)}
</MetricCard>
);
}
@@ -0,0 +1,62 @@
import { UserCheck, UserX } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/main-axios";
import { MetricCard } from "./MetricCard";
export function LoginStatsCard({ metrics }: { metrics: ServerMetrics | null }) {
const { t } = useTranslation();
const loginStats = metrics?.login_stats;
const recentLogins = loginStats?.recentLogins ?? [];
const failedLogins = loginStats?.failedLogins ?? [];
const allLogins = [
...recentLogins.map((l) => ({ ...l, status: "success" as const })),
...failedLogins.map((l) => ({ ...l, status: "failed" as const })),
]
.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime())
.slice(0, 10);
return (
<MetricCard
title={t("hostMetrics.loginStats")}
icon={<UserCheck className="size-3.5" />}
scroll
>
{allLogins.length === 0 ? (
<span className="py-2 text-xs italic text-muted-foreground">
{t("hostMetrics.noRecentLoginData")}
</span>
) : (
<div className="flex flex-col gap-2">
{allLogins.map((login, i) => (
<div
key={i}
className={`flex items-center justify-between border p-2 ${login.status === "success" ? "border-border bg-muted/30" : "border-destructive/30 bg-destructive/5"}`}
>
<div className="flex min-w-0 flex-col">
<div className="flex items-center gap-1.5">
{login.status === "failed" ? (
<UserX className="size-3 shrink-0 text-destructive" />
) : (
<UserCheck className="size-3 shrink-0 text-accent-brand" />
)}
<span
className={`truncate text-xs font-bold ${login.status === "failed" ? "text-destructive" : ""}`}
>
{login.user}
</span>
</div>
<span className="truncate font-mono text-[10px] text-muted-foreground">
{login.ip}
</span>
</div>
<span className="shrink-0 text-[10px] text-muted-foreground">
{new Date(login.time).toLocaleTimeString()}
</span>
</div>
))}
</div>
)}
</MetricCard>
);
}
@@ -0,0 +1,54 @@
import { MemoryStick } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/main-axios";
import { RadialGauge, Sparkline, MiniStat } from "@/components/charts";
import { MetricCard } from "./MetricCard";
export function MemoryCard({
metrics,
history,
}: {
metrics: ServerMetrics | null;
history: number[];
}) {
const { t } = useTranslation();
const percent = metrics?.memory?.percent ?? null;
const usedGiB = metrics?.memory?.usedGiB ?? null;
const totalGiB = metrics?.memory?.totalGiB ?? null;
const freeGiB =
usedGiB !== null && totalGiB !== null ? totalGiB - usedGiB : null;
return (
<MetricCard
title={t("hostMetrics.memoryUsage")}
icon={<MemoryStick className="size-3.5" />}
>
<div className="flex h-full flex-col gap-3">
<div className="flex items-center gap-4">
<RadialGauge value={percent} />
<div className="flex min-w-0 flex-1 flex-col gap-2">
<div className="grid grid-cols-2 gap-2">
<MiniStat
caption={t("hostMetrics.memory")}
value={
usedGiB !== null && totalGiB !== null
? `${usedGiB.toFixed(1)}/${totalGiB.toFixed(1)}G`
: "N/A"
}
/>
<MiniStat
caption={t("hostMetrics.free")}
value={freeGiB !== null ? `${freeGiB.toFixed(1)}G` : "N/A"}
/>
</div>
<Sparkline
data={[...history, percent ?? 0]}
domain={[0, 100]}
height={48}
/>
</div>
</div>
</div>
</MetricCard>
);
}
@@ -0,0 +1,53 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
/** Default cap (px) for a scrollable card body before it scrolls. */
const DEFAULT_SCROLL_MAX = 360;
/**
* Card shell for Host Metrics tiles. In the masonry grid tiles are
* content-sized (auto height), so the card grows to fit its content. Scrollable
* cards (long lists/logs) cap their body at `scrollMax` px and scroll beyond,
* which keeps the masonry tidy while short cards stay compact.
*/
export function MetricCard({
title,
icon,
action,
children,
bodyClassName,
scroll = false,
scrollMax = DEFAULT_SCROLL_MAX,
}: {
title: string;
icon: ReactNode;
action?: ReactNode;
children: ReactNode;
bodyClassName?: string;
/** When true the body caps its height and scrolls instead of growing. */
scroll?: boolean;
/** Max body height in px for scrollable cards. */
scrollMax?: number;
}) {
return (
<div className="flex min-h-0 flex-col overflow-hidden border border-border bg-card">
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-2.5">
<span className="text-muted-foreground">{icon}</span>
<span className="flex-1 truncate text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{title}
</span>
{action && <div className="ml-auto shrink-0">{action}</div>}
</div>
<div
className={cn(
"min-h-0 flex-1 px-3 py-3 md:px-4",
scroll ? "overflow-y-auto thin-scrollbar" : "overflow-hidden",
bodyClassName,
)}
style={scroll ? { maxHeight: scrollMax } : undefined}
>
{children}
</div>
</div>
);
}
@@ -0,0 +1,66 @@
import { Cable, Container, Network, Wifi, WifiOff } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/main-axios";
import { MetricCard } from "./MetricCard";
function ifaceIcon(name: string) {
if (/^(wl|wlan|wifi|ath)/.test(name))
return <Wifi className="size-3 text-muted-foreground/60" />;
if (/^(eth|en|enp|eno|ens)/.test(name))
return <Cable className="size-3 text-muted-foreground/60" />;
if (/^(docker|br-|veth|virbr|vlan|bond|tun|tap|wg|lo)/.test(name))
return <Container className="size-3 text-muted-foreground/60" />;
return <Network className="size-3 text-muted-foreground/60" />;
}
export function NetworkCard({ metrics }: { metrics: ServerMetrics | null }) {
const { t } = useTranslation();
const interfaces = metrics?.network?.interfaces ?? [];
return (
<MetricCard
title={t("hostMetrics.networkInterfaces")}
icon={<Network className="size-3.5" />}
scroll
>
{interfaces.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-6 text-muted-foreground">
<WifiOff className="size-6 opacity-40" />
<span className="text-xs">{t("hostMetrics.noInterfacesFound")}</span>
</div>
) : (
<div className="flex flex-col gap-2">
{interfaces.map((iface, i) => (
<div
key={`${iface.name}-${i}`}
className="flex flex-col gap-1 border border-border bg-muted/30 p-2"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{ifaceIcon(iface.name)}
<span
className={`size-1.5 rounded-full ${iface.state === "UP" ? "bg-accent-brand" : "bg-muted-foreground/50"}`}
/>
<span className="font-mono text-sm font-bold">
{iface.name}
</span>
</div>
<span className="border border-border px-1.5 py-px text-[10px] font-semibold uppercase text-muted-foreground">
{iface.state}
</span>
</div>
<div className="flex justify-between font-mono text-[10px] text-muted-foreground">
<span className="truncate">{iface.ip}</span>
{(iface.rx || iface.tx) && (
<span className="shrink-0">
{iface.rx ?? "—"} / {iface.tx ?? "—"}
</span>
)}
</div>
</div>
))}
</div>
)}
</MetricCard>
);
}
@@ -0,0 +1,101 @@
import { useMemo, useState } from "react";
import { Unplug, Search } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics, ListeningPort } from "@/main-axios";
import { MetricCard } from "./MetricCard";
function formatAddress(addr: string) {
return addr === "0.0.0.0" || addr === "*" || addr === "::" ? "*" : addr;
}
function PortRow({ port }: { port: ListeningPort }) {
return (
<div className="grid grid-cols-[3.5rem_3rem_1fr_4rem] gap-2 overflow-hidden border-b border-border/50 py-1 font-mono text-xs last:border-0">
<span className="truncate font-bold text-accent-brand">
{port.localPort}
</span>
<span className="truncate text-muted-foreground">
{port.protocol.toUpperCase()}
</span>
<span className="truncate font-semibold">
{port.process ?? (port.pid ? `PID:${port.pid}` : "—")}
</span>
<span className="truncate text-right text-muted-foreground">
{formatAddress(port.localAddress)}
</span>
</div>
);
}
export function PortsCard({ metrics }: { metrics: ServerMetrics | null }) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const [proto, setProto] = useState<"all" | "tcp" | "udp">("all");
const ports = useMemo(() => metrics?.ports?.ports ?? [], [metrics]);
const filtered = useMemo(() => {
const q = query.toLowerCase();
return ports.filter((p) => {
if (proto !== "all" && p.protocol.toLowerCase() !== proto) return false;
if (!q) return true;
return (
String(p.localPort).includes(q) ||
(p.process ?? "").toLowerCase().includes(q) ||
String(p.pid ?? "").includes(q) ||
p.localAddress.toLowerCase().includes(q)
);
});
}, [ports, query, proto]);
return (
<MetricCard
title={t("hostMetrics.ports.title")}
icon={<Unplug className="size-3.5" />}
scroll
>
<div className="mb-2 flex items-center gap-1.5">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-2 top-1/2 size-3 -translate-y-1/2 text-muted-foreground/60" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("hostMetrics.ports.search")}
className="h-7 w-full border border-border bg-background pl-7 pr-2 text-xs outline-none focus:ring-1 focus:ring-ring"
/>
</div>
<select
value={proto}
onChange={(e) => setProto(e.target.value as "all" | "tcp" | "udp")}
className="h-7 border border-border bg-background px-1 text-xs"
>
<option value="all">{t("hostMetrics.ports.allProtocols")}</option>
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</select>
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground">
{filtered.length}
</span>
</div>
<div className="flex flex-col">
<div className="grid grid-cols-[3.5rem_3rem_1fr_4rem] gap-2 border-b border-border pb-1 text-[10px] font-bold uppercase text-muted-foreground">
<span>{t("hostMetrics.ports.port")}</span>
<span>{t("hostMetrics.ports.protocol")}</span>
<span>{t("hostMetrics.ports.process")}</span>
<span className="text-right">{t("hostMetrics.ports.address")}</span>
</div>
{filtered.length === 0 ? (
<span className="py-2 text-xs italic text-muted-foreground">
{t("hostMetrics.ports.noData")}
</span>
) : (
filtered.map((port, i) => (
<PortRow
key={`${port.protocol}-${port.localPort}-${i}`}
port={port}
/>
))
)}
</div>
</MetricCard>
);
}
@@ -0,0 +1,54 @@
import { Cpu } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/main-axios";
import { StatRow } from "@/components/charts";
import { MetricCard } from "./MetricCard";
export function ProcessesCard({ metrics }: { metrics: ServerMetrics | null }) {
const { t } = useTranslation();
const procs = metrics?.processes;
const top = procs?.top ?? [];
return (
<MetricCard
title={t("hostMetrics.processes")}
icon={<Cpu className="size-3.5" />}
scroll
>
<div className="mb-2 flex items-center gap-4 text-xs">
{procs?.total != null && (
<span>
<span className="font-bold text-foreground">{procs.total}</span>{" "}
<span className="text-muted-foreground">
{t("hostMetrics.processesTotal")}
</span>
</span>
)}
{procs?.running != null && (
<span>
<span className="font-bold text-accent-brand">{procs.running}</span>{" "}
<span className="text-muted-foreground">
{t("hostMetrics.processesRunning")}
</span>
</span>
)}
</div>
{top.length === 0 ? (
<span className="text-xs text-muted-foreground">
{t("hostMetrics.noProcessesFound")}
</span>
) : (
<div className="flex flex-col divide-y divide-border">
{top.slice(0, 10).map((p) => (
<StatRow
key={p.pid}
label={p.command}
value={`${p.cpu}% cpu · ${p.mem}% mem`}
mono
/>
))}
</div>
)}
</MetricCard>
);
}
@@ -0,0 +1,37 @@
import { Server } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/main-axios";
import { StatRow } from "@/components/charts";
import { MetricCard } from "./MetricCard";
export function SystemCard({ metrics }: { metrics: ServerMetrics | null }) {
const { t } = useTranslation();
const system = metrics?.system;
const uptime = metrics?.uptime;
const rows = [
{ label: t("hostMetrics.hostname"), value: system?.hostname },
{ label: t("hostMetrics.operatingSystem"), value: system?.os },
{ label: t("hostMetrics.kernel"), value: system?.kernel },
{ label: t("hostMetrics.architecture"), value: system?.arch },
{ label: t("hostMetrics.uptime"), value: uptime?.formatted },
].filter((r) => r.value);
return (
<MetricCard
title={t("hostMetrics.systemInfo")}
icon={<Server className="size-3.5" />}
scroll
>
{rows.length === 0 ? (
<span className="text-xs text-muted-foreground">N/A</span>
) : (
<div className="flex flex-col divide-y divide-border">
{rows.map(({ label, value }) => (
<StatRow key={label} label={label} value={value} mono />
))}
</div>
)}
</MetricCard>
);
}
@@ -0,0 +1,28 @@
import { Clock } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/main-axios";
import { MetricCard } from "./MetricCard";
export function UptimeCard({ metrics }: { metrics: ServerMetrics | null }) {
const { t } = useTranslation();
const uptime = metrics?.uptime;
return (
<MetricCard
title={t("hostMetrics.uptime")}
icon={<Clock className="size-3.5" />}
>
<div className="flex h-full flex-col justify-center gap-2">
<span className="text-2xl font-bold leading-none text-accent-brand md:text-3xl">
{uptime?.formatted ?? "N/A"}
</span>
{uptime?.seconds != null && (
<span className="font-mono text-xs text-muted-foreground">
{Math.floor(uptime.seconds).toLocaleString()}{" "}
{t("hostMetrics.seconds")}
</span>
)}
</div>
</MetricCard>
);
}
@@ -0,0 +1,191 @@
import type { ComponentType, ReactNode } from "react";
import type { ServerMetrics } from "@/main-axios";
import type { HostMetricsCardId } from "@/types/host-metrics";
import { defaultColSpanFor, defaultHeightFor } from "@/types/host-metrics";
import { CpuCard } from "./CpuCard";
import { MemoryCard } from "./MemoryCard";
import { DiskCard } from "./DiskCard";
import { NetworkCard } from "./NetworkCard";
import { UptimeCard } from "./UptimeCard";
import { SystemCard } from "./SystemCard";
import { LoginStatsCard } from "./LoginStatsCard";
import { PortsCard } from "./PortsCard";
import { ProcessesCard } from "./ProcessesCard";
import { FirewallCard } from "./FirewallCard";
import { ServiceManagerCard } from "./managers/ServiceManagerCard";
import { ProcessInspectorCard } from "./managers/ProcessInspectorCard";
import { PackageManagerCard } from "./managers/PackageManagerCard";
import { CronManagerCard } from "./managers/CronManagerCard";
import { SslManagerCard } from "./managers/SslManagerCard";
import { FirewallManagerCard } from "./managers/FirewallManagerCard";
import { UserManagerCard } from "./managers/UserManagerCard";
import { HealthCheckCard } from "./managers/HealthCheckCard";
import { LogViewerCard } from "./managers/LogViewerCard";
import {
TopMemoryCard,
SystemdTimersCard,
DiskBreakdownCard,
} from "./managers/SimpleManagerCards";
export interface MetricCardHistories {
cpu: number[];
memory: number[];
disk: number[];
}
export interface CardRenderContext {
metrics: ServerMetrics | null;
histories: MetricCardHistories;
hostId: number | null;
}
export interface CardDefinition {
id: HostMetricsCardId;
/** i18n key for the card's display label. */
labelKey: string;
kind: "metric" | "manager";
render: (ctx: CardRenderContext) => ReactNode;
}
type SimpleMetricCard = ComponentType<{ metrics: ServerMetrics | null }>;
type HistoryMetricCard = ComponentType<{
metrics: ServerMetrics | null;
history: number[];
}>;
function metricCard(
id: HostMetricsCardId,
labelKey: string,
Comp: SimpleMetricCard,
): CardDefinition {
return {
id,
labelKey,
kind: "metric",
render: ({ metrics }) => <Comp metrics={metrics} />,
};
}
function historyCard(
id: HostMetricsCardId,
labelKey: string,
Comp: HistoryMetricCard,
series: keyof MetricCardHistories,
): CardDefinition {
return {
id,
labelKey,
kind: "metric",
render: ({ metrics, histories }) => (
<Comp metrics={metrics} history={histories[series]} />
),
};
}
type ManagerCardComp = ComponentType<{ hostId: number | null }>;
function managerCard(
id: HostMetricsCardId,
labelKey: string,
Comp: ManagerCardComp,
): CardDefinition {
return {
id,
labelKey,
kind: "manager",
render: ({ hostId }) => <Comp hostId={hostId} />,
};
}
export const CARD_DEFINITIONS: Record<string, CardDefinition> = {
cpu: historyCard("cpu", "hostMetrics.cpuUsage", CpuCard, "cpu"),
memory: historyCard(
"memory",
"hostMetrics.memoryUsage",
MemoryCard,
"memory",
),
disk: historyCard("disk", "hostMetrics.diskUsage", DiskCard, "disk"),
network: metricCard("network", "hostMetrics.networkInterfaces", NetworkCard),
uptime: metricCard("uptime", "hostMetrics.uptime", UptimeCard),
system: metricCard("system", "hostMetrics.systemInfo", SystemCard),
login_stats: metricCard(
"login_stats",
"hostMetrics.loginStats",
LoginStatsCard,
),
ports: metricCard("ports", "hostMetrics.ports.title", PortsCard),
processes: metricCard("processes", "hostMetrics.processes", ProcessesCard),
firewall: metricCard("firewall", "hostMetrics.firewall.title", FirewallCard),
service_manager: managerCard(
"service_manager",
"hostMetrics.managers.services",
ServiceManagerCard,
),
process_inspector: managerCard(
"process_inspector",
"hostMetrics.managers.processInspector",
ProcessInspectorCard,
),
log_viewer: managerCard(
"log_viewer",
"hostMetrics.managers.logViewer",
LogViewerCard,
),
cron_manager: managerCard(
"cron_manager",
"hostMetrics.managers.cron",
CronManagerCard,
),
package_manager: managerCard(
"package_manager",
"hostMetrics.managers.packages",
PackageManagerCard,
),
ssl_manager: managerCard(
"ssl_manager",
"hostMetrics.managers.ssl",
SslManagerCard,
),
firewall_manager: managerCard(
"firewall_manager",
"hostMetrics.managers.firewall",
FirewallManagerCard,
),
user_manager: managerCard(
"user_manager",
"hostMetrics.managers.users",
UserManagerCard,
),
health_check: managerCard(
"health_check",
"hostMetrics.managers.healthCheck",
HealthCheckCard,
),
disk_breakdown: managerCard(
"disk_breakdown",
"hostMetrics.managers.diskBreakdown",
DiskBreakdownCard,
),
systemd_timers: managerCard(
"systemd_timers",
"hostMetrics.managers.systemdTimers",
SystemdTimersCard,
),
top_memory: managerCard(
"top_memory",
"hostMetrics.managers.topMemory",
TopMemoryCard,
),
};
/** All card ids implemented and available in the Add tray. */
export const IMPLEMENTED_CARD_IDS = Object.keys(
CARD_DEFINITIONS,
) as HostMetricsCardId[];
export function getCardDefinition(id: string): CardDefinition | undefined {
return CARD_DEFINITIONS[id];
}
export { defaultColSpanFor, defaultHeightFor };
@@ -0,0 +1,162 @@
import { useEffect, useMemo, useState } from "react";
import { Clock4, Plus, Trash2, Save } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { managerPost } from "@/main-axios";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { ManagerSearch } from "./ManagerToolbar";
interface CronEntry {
raw: string;
enabled: boolean;
schedule: string;
command: string;
}
export function CronManagerCard({ hostId }: { hostId: number | null }) {
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<{
entries: CronEntry[];
}>(hostId, "cron");
const [entries, setEntries] = useState<CronEntry[]>([]);
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [query, setQuery] = useState("");
// Keep the real array index so edits/removes target the right entry even when
// the list is filtered by the search box.
const visible = useMemo(() => {
const q = query.toLowerCase();
return entries
.map((entry, index) => ({ entry, index }))
.filter(
({ entry }) =>
!q ||
entry.schedule.toLowerCase().includes(q) ||
entry.command.toLowerCase().includes(q),
);
}, [entries, query]);
useEffect(() => {
if (data?.entries) {
setEntries(data.entries);
setDirty(false);
}
}, [data]);
const update = (i: number, patch: Partial<CronEntry>) => {
setEntries((prev) =>
prev.map((e, idx) => (idx === i ? { ...e, ...patch } : e)),
);
setDirty(true);
};
const remove = (i: number) => {
setEntries((prev) => prev.filter((_, idx) => idx !== i));
setDirty(true);
};
const add = () => {
setEntries((prev) => [
...prev,
{ raw: "", enabled: true, schedule: "0 * * * *", command: "" },
]);
setDirty(true);
};
const save = async () => {
if (hostId == null) return;
setSaving(true);
try {
const res = await managerPost<{ success: boolean; output: string }>(
hostId,
"cron",
{ entries },
);
if (res.success) {
toast.success(t("hostMetrics.managers.cronSaved"));
setDirty(false);
refresh();
} else {
toast.error(res.output || t("hostMetrics.managers.actionFailed"));
}
} catch (e) {
toast.error(extractError(e).message);
} finally {
setSaving(false);
}
};
return (
<ManagerCardShell
title={t("hostMetrics.managers.cron")}
icon={<Clock4 className="size-3.5" />}
loading={loading}
error={error}
onRefresh={refresh}
headerExtra={
<>
<Button variant="ghost" size="xs" onClick={add}>
<Plus className="size-3" />
</Button>
{dirty && (
<Button
variant="outline"
size="xs"
disabled={saving}
onClick={save}
>
<Save className="size-3" />
{t("hostMetrics.managers.save")}
</Button>
)}
</>
}
empty={!loading && entries.length === 0}
>
{entries.length > 5 && (
<ManagerSearch
value={query}
onChange={setQuery}
count={visible.length}
/>
)}
<div className="flex flex-col gap-2">
{visible.map(({ entry: e, index: i }) => (
<div
key={i}
className="flex flex-col gap-1 border border-border bg-muted/20 p-2"
>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={e.enabled}
onChange={(ev) => update(i, { enabled: ev.target.checked })}
className="accent-accent-brand"
title={t("hostMetrics.managers.enabled")}
/>
<input
value={e.schedule}
onChange={(ev) => update(i, { schedule: ev.target.value })}
placeholder="0 * * * *"
className="h-6 w-28 border border-border bg-background px-1.5 font-mono text-[11px] outline-none focus:ring-1 focus:ring-ring"
/>
<button
onClick={() => remove(i)}
className="ml-auto text-muted-foreground hover:text-destructive"
>
<Trash2 className="size-3.5" />
</button>
</div>
<input
value={e.command}
onChange={(ev) => update(i, { command: ev.target.value })}
placeholder={t("hostMetrics.managers.command")}
className="h-6 w-full border border-border bg-background px-1.5 font-mono text-[11px] outline-none focus:ring-1 focus:ring-ring"
/>
</div>
))}
</div>
</ManagerCardShell>
);
}
@@ -0,0 +1,230 @@
import { useMemo, useState } from "react";
import { ShieldCheck, Plus, Trash2, Save } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { managerPost } from "@/main-axios";
import type { FirewallChain } from "@/main-axios";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { ManagerSearch } from "./ManagerToolbar";
interface FirewallData {
type: "iptables" | "nftables" | "none";
status: string;
chains: FirewallChain[];
}
export function FirewallManagerCard({ hostId }: { hostId: number | null }) {
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<FirewallData>(
hostId,
"firewall",
);
const [proto, setProto] = useState<"tcp" | "udp">("tcp");
const [port, setPort] = useState("");
const [target, setTarget] = useState<"ACCEPT" | "DROP" | "REJECT">("ACCEPT");
const [busy, setBusy] = useState(false);
const [query, setQuery] = useState("");
const rule = async (op: "add" | "delete") => {
if (hostId == null) return;
const portNum = Number(port);
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) {
toast.error(t("hostMetrics.managers.invalidPort"));
return;
}
setBusy(true);
try {
const res = await managerPost<{ success: boolean; output: string }>(
hostId,
"firewall",
{ op, protocol: proto, port: portNum, target },
"rule",
);
if (res.success) {
toast.success(t("hostMetrics.managers.ruleApplied"));
refresh();
} else {
toast.error(res.output || t("hostMetrics.managers.actionFailed"));
}
} catch (e) {
toast.error(extractError(e).message);
} finally {
setBusy(false);
}
};
const persist = async () => {
if (hostId == null) return;
setBusy(true);
toast.loading(t("hostMetrics.managers.working"), { id: "fw-persist" });
try {
const res = await managerPost<{ success: boolean; output: string }>(
hostId,
"firewall",
{},
"persist",
);
toast[res.success ? "success" : "error"](
res.success
? t("hostMetrics.managers.firewallPersisted")
: t("hostMetrics.managers.actionFailed"),
{ id: "fw-persist", description: res.output?.slice(-200) },
);
} catch (e) {
toast.error(extractError(e).message, { id: "fw-persist" });
} finally {
setBusy(false);
}
};
const chains = useMemo(() => {
const q = query.toLowerCase();
return (data?.chains ?? [])
.map((c) => ({
...c,
rules: c.rules.filter(
(r) =>
!q ||
r.target.toLowerCase().includes(q) ||
(r.dport ?? "").includes(q) ||
r.protocol.toLowerCase().includes(q) ||
r.source.toLowerCase().includes(q),
),
}))
.filter((c) => c.rules.length > 0 || !q);
}, [data?.chains, query]);
return (
<ManagerCardShell
title={t("hostMetrics.managers.firewall")}
icon={<ShieldCheck className="size-3.5" />}
loading={loading}
error={error}
onRefresh={refresh}
headerExtra={
<>
<span className="text-[10px] uppercase tracking-widest text-muted-foreground">
{data?.type ?? ""}
</span>
<Button
variant="ghost"
size="xs"
disabled={busy || data?.type === "none"}
onClick={persist}
title={t("hostMetrics.managers.firewallPersist")}
>
<Save className="size-3" />
</Button>
</>
}
>
<div className="mb-3 flex flex-col gap-2 border border-dashed border-border p-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hostMetrics.managers.addInputRule")}
</span>
<div className="flex items-center gap-1.5">
<select
value={proto}
onChange={(e) => setProto(e.target.value as "tcp" | "udp")}
className="h-7 border border-border bg-background px-1 text-xs"
>
<option value="tcp">tcp</option>
<option value="udp">udp</option>
</select>
<input
value={port}
onChange={(e) => setPort(e.target.value)}
placeholder={t("hostMetrics.ports.port")}
className="h-7 w-16 border border-border bg-background px-1.5 text-xs outline-none focus:ring-1 focus:ring-ring"
/>
<select
value={target}
onChange={(e) =>
setTarget(e.target.value as "ACCEPT" | "DROP" | "REJECT")
}
className="h-7 border border-border bg-background px-1 text-xs"
>
<option>ACCEPT</option>
<option>DROP</option>
<option>REJECT</option>
</select>
<Button
variant="outline"
size="xs"
disabled={busy}
onClick={() => rule("add")}
>
<Plus className="size-3" />
</Button>
<Button
variant="ghost"
size="xs"
disabled={busy}
onClick={() => rule("delete")}
>
<Trash2 className="size-3" />
</Button>
</div>
<span className="text-[10px] text-yellow-500">
{t("hostMetrics.managers.firewallWarning")}
</span>
</div>
<ManagerSearch value={query} onChange={setQuery} />
{chains.length === 0 ? (
<span className="text-xs text-muted-foreground">
{t("hostMetrics.firewall.noData")}
</span>
) : (
<div className="flex flex-col gap-3">
{chains.map((chain) => (
<div key={chain.name} className="flex flex-col">
<div className="mb-1 flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-widest text-foreground">
{chain.name}
</span>
{chain.policy && (
<span className="text-[10px] text-muted-foreground">
{t("hostMetrics.firewall.policy")}: {chain.policy}
</span>
)}
</div>
{chain.rules.length === 0 ? (
<span className="text-[10px] italic text-muted-foreground/60">
{t("hostMetrics.firewall.noRules")}
</span>
) : (
<>
<div className="grid grid-cols-4 gap-2 border-b border-border pb-1 text-[10px] font-bold uppercase text-muted-foreground">
<span>{t("hostMetrics.firewall.action")}</span>
<span>{t("hostMetrics.firewall.protocol")}</span>
<span>{t("hostMetrics.firewall.port")}</span>
<span>{t("hostMetrics.firewall.source")}</span>
</div>
{chain.rules.map((r, i) => (
<div
key={i}
className="grid grid-cols-4 gap-2 border-b border-border/50 py-1 font-mono text-xs last:border-0"
>
<span className="font-bold">{r.target}</span>
<span className="text-muted-foreground">
{r.protocol.toUpperCase()}
</span>
<span>{r.dport ?? "—"}</span>
<span className="truncate text-muted-foreground">
{r.source}
</span>
</div>
))}
</>
)}
</div>
))}
</div>
)}
</ManagerCardShell>
);
}
@@ -0,0 +1,310 @@
import { useEffect, useMemo, useState } from "react";
import {
HeartPulse,
CheckCircle2,
XCircle,
Plus,
Trash2,
Play,
Pencil,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { Sparkline } from "@/components/charts";
import { managerPost } from "@/main-axios";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
interface HealthCheck {
id: string;
name: string;
type: "tcp" | "http";
target: string;
port?: number;
path?: string;
}
interface HealthResult {
checkId: string;
ok: boolean;
latencyMs: number | null;
detail: string;
}
interface HistoryRow {
checkId: string;
ts: string;
ok: boolean;
latencyMs: number | null;
}
interface HealthData {
checks: HealthCheck[];
results: HealthResult[];
history: HistoryRow[];
}
function newCheck(): HealthCheck {
return {
id: `c_${Math.random().toString(36).slice(2, 9)}`,
name: "",
type: "tcp",
target: "",
port: 80,
};
}
export function HealthCheckCard({ hostId }: { hostId: number | null }) {
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<HealthData>(
hostId,
"health",
);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<HealthCheck[]>([]);
const [saving, setSaving] = useState(false);
const [running, setRunning] = useState(false);
useEffect(() => {
if (data?.checks && !editing) setDraft(data.checks);
}, [data?.checks, editing]);
const byCheck = useMemo(() => {
const map = new Map<string, HistoryRow[]>();
for (const h of data?.history ?? []) {
const arr = map.get(h.checkId) ?? [];
arr.push(h);
map.set(h.checkId, arr);
}
return map;
}, [data?.history]);
const save = async () => {
if (hostId == null) return;
for (const c of draft) {
if (!c.name.trim() || !c.target.trim()) {
toast.error(t("hostMetrics.managers.healthMissingFields"));
return;
}
}
setSaving(true);
try {
const res = await managerPost<{ success: boolean }>(
hostId,
"health",
{ checks: draft },
"config",
);
if (res.success) {
toast.success(t("hostMetrics.managers.healthSaved"));
setEditing(false);
refresh();
} else {
toast.error(t("hostMetrics.managers.actionFailed"));
}
} catch (e) {
toast.error(extractError(e).message);
} finally {
setSaving(false);
}
};
const runNow = async () => {
if (hostId == null) return;
setRunning(true);
try {
await managerPost(hostId, "health", {}, "run");
refresh();
} catch (e) {
toast.error(extractError(e).message);
} finally {
setRunning(false);
}
};
const updateDraft = (id: string, patch: Partial<HealthCheck>) =>
setDraft((d) => d.map((c) => (c.id === id ? { ...c, ...patch } : c)));
return (
<ManagerCardShell
title={t("hostMetrics.managers.healthCheck")}
icon={<HeartPulse className="size-3.5" />}
loading={loading}
error={error}
onRefresh={refresh}
headerExtra={
editing ? (
<>
<Button
variant="ghost"
size="xs"
onClick={() => {
setEditing(false);
setDraft(data?.checks ?? []);
}}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
size="xs"
disabled={saving}
onClick={save}
>
{t("common.save")}
</Button>
</>
) : (
<>
<Button
variant="ghost"
size="xs"
disabled={running || (data?.checks?.length ?? 0) === 0}
onClick={runNow}
>
<Play className="size-3" />
{t("hostMetrics.managers.healthRun")}
</Button>
<Button variant="ghost" size="xs" onClick={() => setEditing(true)}>
<Pencil className="size-3" />
{t("common.edit")}
</Button>
</>
)
}
>
{editing ? (
<div className="flex flex-col gap-2">
{draft.map((c) => (
<div
key={c.id}
className="flex flex-col gap-1.5 border border-border bg-muted/20 p-2"
>
<div className="flex items-center gap-1.5">
<input
value={c.name}
onChange={(e) => updateDraft(c.id, { name: e.target.value })}
placeholder={t("hostMetrics.managers.healthName")}
className="h-7 flex-1 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
/>
<select
value={c.type}
onChange={(e) =>
updateDraft(c.id, {
type: e.target.value as "tcp" | "http",
})
}
className="h-7 border border-border bg-background px-1 text-xs"
>
<option value="tcp">TCP</option>
<option value="http">HTTP</option>
</select>
<button
onClick={() =>
setDraft((d) => d.filter((x) => x.id !== c.id))
}
className="flex size-7 items-center justify-center text-muted-foreground hover:text-destructive"
>
<Trash2 className="size-3.5" />
</button>
</div>
<div className="flex items-center gap-1.5">
<input
value={c.target}
onChange={(e) =>
updateDraft(c.id, { target: e.target.value })
}
placeholder={t("hostMetrics.managers.healthTarget")}
className="h-7 flex-1 border border-border bg-background px-2 font-mono text-xs outline-none focus:ring-1 focus:ring-ring"
/>
{c.type === "tcp" ? (
<input
type="number"
value={c.port ?? ""}
onChange={(e) =>
updateDraft(c.id, { port: Number(e.target.value) })
}
placeholder={t("hostMetrics.ports.port")}
className="h-7 w-16 border border-border bg-background px-1.5 text-xs outline-none focus:ring-1 focus:ring-ring"
/>
) : (
<input
value={c.path ?? ""}
onChange={(e) =>
updateDraft(c.id, { path: e.target.value })
}
placeholder="/health"
className="h-7 w-24 border border-border bg-background px-1.5 font-mono text-xs outline-none focus:ring-1 focus:ring-ring"
/>
)}
</div>
</div>
))}
<Button
variant="ghost"
size="sm"
className="self-start"
onClick={() => setDraft((d) => [...d, newCheck()])}
>
<Plus className="size-3" />
{t("hostMetrics.managers.healthAddCheck")}
</Button>
</div>
) : (data?.checks?.length ?? 0) === 0 ? (
<div className="flex flex-col items-center gap-2 py-6 text-center">
<span className="text-xs text-muted-foreground/60">
{t("hostMetrics.managers.noHealthChecks")}
</span>
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>
<Plus className="size-3" />
{t("hostMetrics.managers.healthAddCheck")}
</Button>
</div>
) : (
<div className="flex flex-col gap-3">
{(data?.checks ?? []).map((check) => {
const result = data?.results?.find((r) => r.checkId === check.id);
const hist = (byCheck.get(check.id) ?? []).slice().reverse();
const upPct =
hist.length > 0
? Math.round(
(hist.filter((h) => h.ok).length / hist.length) * 100,
)
: null;
return (
<div
key={check.id}
className="flex flex-col gap-1 border border-border bg-muted/20 p-2"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{result?.ok ? (
<CheckCircle2 className="size-3.5 text-accent-brand" />
) : (
<XCircle className="size-3.5 text-destructive" />
)}
<span className="text-xs font-semibold">{check.name}</span>
<span className="font-mono text-[10px] text-muted-foreground/60">
{check.type === "tcp"
? `${check.target}:${check.port}`
: check.target}
</span>
</div>
<span className="font-mono text-[10px] text-muted-foreground">
{result?.latencyMs != null ? `${result.latencyMs}ms` : "—"}
{upPct != null ? ` · ${upPct}%` : ""}
</span>
</div>
{hist.length > 1 && (
<Sparkline
data={hist.map((h) => h.latencyMs ?? 0)}
height={28}
showLastDot={false}
/>
)}
</div>
);
})}
</div>
)}
</ManagerCardShell>
);
}
@@ -0,0 +1,183 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { ScrollText } from "lucide-react";
import { useTranslation } from "react-i18next";
import { managerGet, managerGetSub } from "@/main-axios";
import { extractError } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
interface LogFiles {
common: string[];
files: string[];
}
type Mode = "file" | "unit";
export function LogViewerCard({ hostId }: { hostId: number | null }) {
const { t } = useTranslation();
const [mode, setMode] = useState<Mode>("file");
const [path, setPath] = useState<string>("/var/log/syslog");
const [unit, setUnit] = useState<string>("");
const [customPath, setCustomPath] = useState("");
const [lines, setLines] = useState(300);
const [content, setContent] = useState("");
const [grep, setGrep] = useState("");
const [follow, setFollow] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [files, setFiles] = useState<string[]>([]);
const bodyRef = useRef<HTMLPreElement | null>(null);
// Load the host's actual log files once.
useEffect(() => {
if (hostId == null) return;
managerGetSub<LogFiles>(hostId, "logs", "files")
.then((res) => {
const merged = Array.from(
new Set([...(res.common ?? []), ...(res.files ?? [])]),
);
setFiles(merged);
if (merged.length && !merged.includes(path)) setPath(merged[0]);
})
.catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hostId]);
const fetchLog = async () => {
if (hostId == null) return;
const params: Record<string, string | number> = { lines };
if (mode === "unit") {
if (!unit.trim()) return;
params.unit = unit.trim();
} else {
const p = customPath.trim() || path;
if (!p) return;
params.path = p;
}
setLoading(true);
setError(null);
try {
const res = await managerGet<{ content: string }>(hostId, "logs", params);
setContent(res.content || "");
requestAnimationFrame(() => {
if (bodyRef.current)
bodyRef.current.scrollTop = bodyRef.current.scrollHeight;
});
} catch (e) {
setError(extractError(e).message);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchLog();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, path, unit, lines]);
useEffect(() => {
if (!follow) return;
const id = setInterval(fetchLog, 3000);
return () => clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [follow, mode, path, unit, lines, customPath]);
const shown = useMemo(() => {
if (!grep) return content;
const needle = grep.toLowerCase();
return content
.split("\n")
.filter((l) => l.toLowerCase().includes(needle))
.join("\n");
}, [content, grep]);
return (
<ManagerCardShell
title={t("hostMetrics.managers.logViewer")}
icon={<ScrollText className="size-3.5" />}
loading={loading}
error={error ? { message: error } : null}
onRefresh={fetchLog}
headerExtra={
<label className="flex items-center gap-1 text-[10px] text-muted-foreground">
<input
type="checkbox"
checked={follow}
onChange={(e) => setFollow(e.target.checked)}
className="accent-accent-brand"
/>
{t("hostMetrics.managers.follow")}
</label>
}
>
<div className="mb-2 flex items-center gap-1.5">
<select
value={mode}
onChange={(e) => setMode(e.target.value as Mode)}
className="h-7 border border-border bg-background px-1 text-[11px]"
>
<option value="file">{t("hostMetrics.managers.logFile")}</option>
<option value="unit">{t("hostMetrics.managers.logUnit")}</option>
</select>
{mode === "file" ? (
<select
value={customPath ? "" : path}
onChange={(e) => {
setCustomPath("");
setPath(e.target.value);
}}
className="h-7 flex-1 border border-border bg-background px-2 font-mono text-[11px] outline-none focus:ring-1 focus:ring-ring"
>
{files.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
) : (
<input
value={unit}
onChange={(e) => setUnit(e.target.value)}
onBlur={fetchLog}
placeholder="nginx.service"
className="h-7 flex-1 border border-border bg-background px-2 font-mono text-[11px] outline-none focus:ring-1 focus:ring-ring"
/>
)}
<select
value={lines}
onChange={(e) => setLines(Number(e.target.value))}
className="h-7 border border-border bg-background px-1 text-[11px]"
>
{[100, 300, 500, 1000, 2000].map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</div>
{mode === "file" && (
<input
value={customPath}
onChange={(e) => setCustomPath(e.target.value)}
onBlur={() => customPath && fetchLog()}
placeholder={t("hostMetrics.managers.logCustomPath")}
className="mb-2 h-7 w-full border border-border bg-background px-2 font-mono text-[11px] outline-none focus:ring-1 focus:ring-ring"
/>
)}
<input
value={grep}
onChange={(e) => setGrep(e.target.value)}
placeholder={t("hostMetrics.managers.logGrep")}
className="mb-2 h-7 w-full border border-border bg-background px-2 text-[11px] outline-none focus:ring-1 focus:ring-ring"
/>
<pre
ref={bodyRef}
className="max-h-[320px] overflow-auto whitespace-pre-wrap break-all border border-border/50 bg-muted/20 p-2 font-mono text-[10px] leading-relaxed"
>
{shown || t("hostMetrics.managers.noLogData")}
</pre>
</ManagerCardShell>
);
}
@@ -0,0 +1,75 @@
import type { ReactNode } from "react";
import { RefreshCw, AlertTriangle } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/button";
import { MetricCard } from "../MetricCard";
import type { ManagerError } from "./useManagerData";
export function ManagerCardShell({
title,
icon,
loading,
error,
onRefresh,
empty,
emptyMessage,
children,
headerExtra,
}: {
title: string;
icon: ReactNode;
loading: boolean;
error: ManagerError | null;
onRefresh: () => void;
empty?: boolean;
emptyMessage?: string;
children: ReactNode;
headerExtra?: ReactNode;
}) {
const { t } = useTranslation();
return (
<MetricCard
title={title}
icon={icon}
scroll
action={
<div className="flex items-center gap-1">
{headerExtra}
<button
onClick={onRefresh}
className="flex size-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
title={t("hostMetrics.refresh")}
>
<RefreshCw className={`size-3 ${loading ? "animate-spin" : ""}`} />
</button>
</div>
}
>
{error ? (
<div className="flex flex-col items-center gap-2 py-6 text-center">
<AlertTriangle className="size-6 text-yellow-500" />
<span className="text-xs text-muted-foreground">{error.message}</span>
{error.code === "SUDO_REQUIRED" && (
<span className="text-[10px] text-muted-foreground">
{t("hostMetrics.managers.sudoHint")}
</span>
)}
<Button
variant="outline"
size="sm"
onClick={onRefresh}
className="mt-1"
>
{t("hostMetrics.retry")}
</Button>
</div>
) : empty ? (
<div className="flex items-center justify-center py-8 text-xs text-muted-foreground/50">
{emptyMessage ?? t("hostMetrics.managers.noData")}
</div>
) : (
children
)}
</MetricCard>
);
}
@@ -0,0 +1,42 @@
import type { ReactNode } from "react";
import { Search } from "lucide-react";
import { useTranslation } from "react-i18next";
/**
* Compact search row shared by manager cards. Optional `extra` renders to the
* right (filters, action buttons) and `count` shows a result tally.
*/
export function ManagerSearch({
value,
onChange,
placeholder,
count,
extra,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
count?: number;
extra?: ReactNode;
}) {
const { t } = useTranslation();
return (
<div className="mb-2 flex items-center gap-1.5">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-2 top-1/2 size-3 -translate-y-1/2 text-muted-foreground/60" />
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder ?? t("hostMetrics.managers.filter")}
className="h-7 w-full border border-border bg-background pl-7 pr-2 text-xs outline-none focus:ring-1 focus:ring-ring"
/>
</div>
{count != null && (
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground">
{count}
</span>
)}
{extra}
</div>
);
}
@@ -0,0 +1,115 @@
import { useMemo, useState } from "react";
import { Package, ArrowUpCircle } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { managerPost } from "@/main-axios";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { ManagerSearch } from "./ManagerToolbar";
interface UpgradablePackage {
name: string;
currentVersion?: string;
newVersion?: string;
}
export function PackageManagerCard({ hostId }: { hostId: number | null }) {
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<{
pkg: string | null;
upgradable: UpgradablePackage[];
}>(hostId, "packages");
const [busy, setBusy] = useState<string | null>(null);
const [filter, setFilter] = useState("");
const all = useMemo(() => data?.upgradable ?? [], [data?.upgradable]);
const pkgs = useMemo(
() =>
all.filter(
(p) => !filter || p.name.toLowerCase().includes(filter.toLowerCase()),
),
[all, filter],
);
const run = async (action: string, pkg?: string) => {
if (hostId == null) return;
setBusy(pkg ?? action);
toast.loading(t("hostMetrics.managers.working"), { id: "pkg-op" });
try {
const res = await managerPost<{ success: boolean; output: string }>(
hostId,
"packages",
{ action, pkg },
"action",
);
toast[res.success ? "success" : "error"](
res.success
? t("hostMetrics.managers.actionDone", { name: pkg ?? "upgrade" })
: t("hostMetrics.managers.actionFailed"),
{ id: "pkg-op", description: res.output?.slice(-200) },
);
if (res.success) refresh();
} catch (e) {
toast.error(extractError(e).message, { id: "pkg-op" });
} finally {
setBusy(null);
}
};
return (
<ManagerCardShell
title={t("hostMetrics.managers.packages")}
icon={<Package className="size-3.5" />}
loading={loading}
error={error}
onRefresh={refresh}
empty={!loading && all.length === 0}
emptyMessage={t("hostMetrics.managers.allUpToDate")}
headerExtra={
all.length > 0 ? (
<Button
variant="outline"
size="xs"
disabled={!!busy}
onClick={() => run("upgrade-all")}
>
<ArrowUpCircle className="size-3" />
{t("hostMetrics.managers.upgradeAll")}
</Button>
) : undefined
}
>
{all.length > 5 && (
<ManagerSearch
value={filter}
onChange={setFilter}
count={pkgs.length}
/>
)}
<div className="flex flex-col">
{pkgs.map((p) => (
<div
key={p.name}
className="flex items-center justify-between gap-2 border-b border-border/50 py-1.5 last:border-0"
>
<div className="flex min-w-0 flex-col">
<span className="truncate text-xs font-semibold">{p.name}</span>
<span className="truncate font-mono text-[10px] text-muted-foreground">
{p.currentVersion ? `${p.currentVersion}` : ""}
{p.newVersion ?? ""}
</span>
</div>
<Button
variant="ghost"
size="xs"
disabled={busy === p.name}
onClick={() => run("install", p.name)}
>
{t("hostMetrics.managers.update")}
</Button>
</div>
))}
</div>
</ManagerCardShell>
);
}
@@ -0,0 +1,190 @@
import { useMemo, useState } from "react";
import { ListTree, X, Skull } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { managerPost } from "@/main-axios";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { ManagerSearch } from "./ManagerToolbar";
interface ProcessRow {
pid: number;
ppid: number;
user: string;
cpu: number;
mem: number;
command: string;
args: string;
}
type SortKey = "cpu" | "mem" | "pid";
type Signal = "TERM" | "KILL";
export function ProcessInspectorCard({ hostId }: { hostId: number | null }) {
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<{
processes: ProcessRow[];
}>(hostId, "processes");
const [filter, setFilter] = useState("");
const [sort, setSort] = useState<SortKey>("cpu");
const [tree, setTree] = useState(false);
const [busy, setBusy] = useState<number | null>(null);
const all = useMemo(() => data?.processes ?? [], [data?.processes]);
const filtered = useMemo(() => {
const q = filter.toLowerCase();
const list = all.filter(
(p) =>
!q ||
p.args.toLowerCase().includes(q) ||
p.command.toLowerCase().includes(q) ||
String(p.pid).includes(q) ||
p.user.toLowerCase().includes(q),
);
return [...list].sort((a, b) => {
if (sort === "pid") return a.pid - b.pid;
return b[sort] - a[sort];
});
}, [all, filter, sort]);
// Build a depth map for the tree view from ppid links (only among visible).
const treeRows = useMemo(() => {
if (!tree) return null;
const byId = new Map(filtered.map((p) => [p.pid, p]));
const children = new Map<number, ProcessRow[]>();
const roots: ProcessRow[] = [];
for (const p of filtered) {
if (p.ppid && byId.has(p.ppid)) {
const arr = children.get(p.ppid) ?? [];
arr.push(p);
children.set(p.ppid, arr);
} else {
roots.push(p);
}
}
const out: Array<{ proc: ProcessRow; depth: number }> = [];
const walk = (p: ProcessRow, depth: number) => {
out.push({ proc: p, depth });
for (const c of children.get(p.pid) ?? []) walk(c, depth + 1);
};
roots.forEach((r) => walk(r, 0));
return out;
}, [tree, filtered]);
const kill = async (pid: number, signal: Signal) => {
if (hostId == null) return;
setBusy(pid);
try {
const res = await managerPost<{ success: boolean; output: string }>(
hostId,
"processes",
{ pid, signal },
"signal",
);
if (res.success) {
toast.success(t("hostMetrics.managers.signalSent", { pid }));
refresh();
} else {
toast.error(res.output || t("hostMetrics.managers.actionFailed"));
}
} catch (e) {
toast.error(extractError(e).message);
} finally {
setBusy(null);
}
};
const rows = treeRows
? treeRows.slice(0, 200)
: filtered.slice(0, 200).map((proc) => ({ proc, depth: 0 }));
return (
<ManagerCardShell
title={t("hostMetrics.managers.processInspector")}
icon={<ListTree className="size-3.5" />}
loading={loading}
error={error}
onRefresh={refresh}
empty={!loading && filtered.length === 0}
headerExtra={
<>
<button
onClick={() => setTree((v) => !v)}
className={`flex h-6 items-center gap-1 border px-1.5 text-[10px] transition-colors ${
tree
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
: "border-border text-muted-foreground hover:text-foreground"
}`}
>
<ListTree className="size-3" />
{t("hostMetrics.managers.tree")}
</button>
<select
value={sort}
onChange={(e) => setSort(e.target.value as SortKey)}
disabled={tree}
className="h-6 border border-border bg-background px-1 text-[10px] disabled:opacity-40"
>
<option value="cpu">CPU</option>
<option value="mem">MEM</option>
<option value="pid">PID</option>
</select>
</>
}
>
<ManagerSearch
value={filter}
onChange={setFilter}
count={filtered.length}
/>
<div className="flex flex-col">
<div className="grid grid-cols-[3rem_2.5rem_2.5rem_1fr_1.5rem] gap-2 border-b border-border pb-1 text-[10px] font-bold uppercase text-muted-foreground">
<span>PID</span>
<span>CPU</span>
<span>MEM</span>
<span>CMD</span>
<span />
</div>
{rows.map(({ proc: p, depth }) => (
<div
key={p.pid}
className="relative grid grid-cols-[3rem_2.5rem_2.5rem_1fr_1.5rem] items-center gap-2 border-b border-border/50 py-1 font-mono text-xs last:border-0"
>
<span className="text-muted-foreground">{p.pid}</span>
<span className="font-bold text-accent-brand">
{p.cpu.toFixed(0)}%
</span>
<span>{p.mem.toFixed(0)}%</span>
<span
className="truncate"
title={`${p.user} · ${p.args}`}
style={depth ? { paddingLeft: depth * 12 } : undefined}
>
{depth > 0 && (
<span className="text-muted-foreground/40"> </span>
)}
{p.command}
</span>
<button
onClick={() => kill(p.pid, "TERM")}
onContextMenu={(e) => {
e.preventDefault();
kill(p.pid, "KILL");
}}
disabled={busy === p.pid}
title={t("hostMetrics.managers.killHint")}
className="flex size-5 items-center justify-center text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-40"
>
{busy === p.pid ? (
<Skull className="size-3" />
) : (
<X className="size-3" />
)}
</button>
</div>
))}
</div>
</ManagerCardShell>
);
}
@@ -0,0 +1,154 @@
import { useMemo, useState } from "react";
import { Cog, Play, Square, RotateCw, Power } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { managerPost } from "@/main-axios";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { ManagerSearch } from "./ManagerToolbar";
interface SystemdService {
unit: string;
active: string;
sub: string;
description: string;
}
export function ServiceManagerCard({ hostId }: { hostId: number | null }) {
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<{
services: SystemdService[];
}>(hostId, "services");
const [filter, setFilter] = useState("");
const [busy, setBusy] = useState<string | null>(null);
const services = useMemo(
() =>
(data?.services ?? []).filter(
(s) =>
!filter ||
s.unit.toLowerCase().includes(filter.toLowerCase()) ||
s.description.toLowerCase().includes(filter.toLowerCase()),
),
[data?.services, filter],
);
const act = async (unit: string, action: string) => {
if (hostId == null) return;
setBusy(`${unit}:${action}`);
try {
const res = await managerPost<{ success: boolean; output: string }>(
hostId,
"services",
{ unit, action },
"action",
);
if (res.success) {
toast.success(t("hostMetrics.managers.actionDone", { name: unit }));
refresh();
} else {
toast.error(res.output || t("hostMetrics.managers.actionFailed"));
}
} catch (e) {
toast.error(extractError(e).message);
} finally {
setBusy(null);
}
};
return (
<ManagerCardShell
title={t("hostMetrics.managers.services")}
icon={<Cog className="size-3.5" />}
loading={loading}
error={error}
onRefresh={refresh}
empty={!loading && services.length === 0}
>
<ManagerSearch
value={filter}
onChange={setFilter}
count={services.length}
/>
<div className="flex flex-col">
{services.map((s) => (
<div
key={s.unit}
className="flex items-center justify-between gap-2 border-b border-border/50 py-1.5 last:border-0"
>
<div className="flex min-w-0 items-center gap-2">
<span
className={`size-1.5 shrink-0 rounded-full ${s.active === "active" ? "bg-accent-brand" : "bg-muted-foreground/40"}`}
/>
<div className="flex min-w-0 flex-col">
<span className="truncate text-xs font-semibold" title={s.unit}>
{s.unit.replace(/\.service$/, "")}
</span>
<span className="truncate text-[10px] text-muted-foreground">
{s.sub}
</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-0.5">
<ActionBtn
onClick={() => act(s.unit, "start")}
busy={busy === `${s.unit}:start`}
title={t("hostMetrics.managers.start")}
>
<Play className="size-3" />
</ActionBtn>
<ActionBtn
onClick={() => act(s.unit, "restart")}
busy={busy === `${s.unit}:restart`}
title={t("hostMetrics.managers.restart")}
>
<RotateCw className="size-3" />
</ActionBtn>
<ActionBtn
onClick={() => act(s.unit, "stop")}
busy={busy === `${s.unit}:stop`}
title={t("hostMetrics.managers.stop")}
>
<Square className="size-3" />
</ActionBtn>
<ActionBtn
onClick={() =>
act(s.unit, s.sub === "running" ? "disable" : "enable")
}
busy={
busy === `${s.unit}:enable` || busy === `${s.unit}:disable`
}
title={t("hostMetrics.managers.enableDisable")}
>
<Power className="size-3" />
</ActionBtn>
</div>
</div>
))}
</div>
</ManagerCardShell>
);
}
function ActionBtn({
onClick,
busy,
title,
children,
}: {
onClick: () => void;
busy: boolean;
title: string;
children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
disabled={busy}
title={title}
className="flex size-6 items-center justify-center text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40"
>
{children}
</button>
);
}
@@ -0,0 +1,165 @@
import { useMemo, useState } from "react";
import { MemoryStick, Timer, HardDrive } from "lucide-react";
import { useTranslation } from "react-i18next";
import { BarSeries, StatRow } from "@/components/charts";
import { useManagerData } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { ManagerSearch } from "./ManagerToolbar";
interface MemProc {
pid: number;
user: string;
mem: number;
rss: number;
command: string;
}
interface TimerRow {
unit: string;
activates: string;
next: string;
}
interface MountUsage {
filesystem: string;
usePct: number;
usedKb: number;
sizeKb: number;
mount: string;
}
function fmtGiB(kb: number): string {
return `${(kb / 1024 / 1024).toFixed(1)}G`;
}
export function TopMemoryCard({ hostId }: { hostId: number | null }) {
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<{
processes: MemProc[];
}>(hostId, "top-memory");
const [query, setQuery] = useState("");
const all = useMemo(() => data?.processes ?? [], [data?.processes]);
const procs = useMemo(() => {
const q = query.toLowerCase();
return all.filter(
(p) =>
!q ||
p.command.toLowerCase().includes(q) ||
String(p.pid).includes(q) ||
p.user.toLowerCase().includes(q),
);
}, [all, query]);
return (
<ManagerCardShell
title={t("hostMetrics.managers.topMemory")}
icon={<MemoryStick className="size-3.5" />}
loading={loading}
error={error}
onRefresh={refresh}
empty={!loading && all.length === 0}
>
{all.length > 5 && (
<ManagerSearch value={query} onChange={setQuery} count={procs.length} />
)}
<BarSeries
items={procs.map((p) => ({
label: `${p.command} (${p.pid})`,
value: p.mem,
valueLabel: `${p.mem.toFixed(1)}%`,
}))}
max={Math.max(1, ...procs.map((p) => p.mem))}
/>
</ManagerCardShell>
);
}
export function SystemdTimersCard({ hostId }: { hostId: number | null }) {
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<{
timers: TimerRow[];
}>(hostId, "timers");
const [query, setQuery] = useState("");
const all = useMemo(() => data?.timers ?? [], [data?.timers]);
const timers = useMemo(() => {
const q = query.toLowerCase();
return all.filter(
(tm) =>
!q ||
tm.unit.toLowerCase().includes(q) ||
tm.activates.toLowerCase().includes(q),
);
}, [all, query]);
return (
<ManagerCardShell
title={t("hostMetrics.managers.systemdTimers")}
icon={<Timer className="size-3.5" />}
loading={loading}
error={error}
onRefresh={refresh}
empty={!loading && all.length === 0}
>
{all.length > 5 && (
<ManagerSearch
value={query}
onChange={setQuery}
count={timers.length}
/>
)}
<div className="flex flex-col divide-y divide-border">
{timers.map((tm) => (
<StatRow
key={tm.unit}
label={tm.unit.replace(/\.timer$/, "")}
value={tm.activates}
mono
/>
))}
</div>
</ManagerCardShell>
);
}
export function DiskBreakdownCard({ hostId }: { hostId: number | null }) {
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<{
mounts: MountUsage[];
}>(hostId, "disk-breakdown");
const [query, setQuery] = useState("");
const all = useMemo(() => data?.mounts ?? [], [data?.mounts]);
const mounts = useMemo(() => {
const q = query.toLowerCase();
return all.filter(
(m) =>
!q ||
m.mount.toLowerCase().includes(q) ||
m.filesystem.toLowerCase().includes(q),
);
}, [all, query]);
return (
<ManagerCardShell
title={t("hostMetrics.managers.diskBreakdown")}
icon={<HardDrive className="size-3.5" />}
loading={loading}
error={error}
onRefresh={refresh}
empty={!loading && all.length === 0}
>
{all.length > 5 && (
<ManagerSearch
value={query}
onChange={setQuery}
count={mounts.length}
/>
)}
<BarSeries
items={mounts.map((m) => ({
label: `${m.mount} (${fmtGiB(m.usedKb)}/${fmtGiB(m.sizeKb)})`,
value: m.usePct,
valueLabel: `${m.usePct}%`,
}))}
max={100}
/>
</ManagerCardShell>
);
}
@@ -0,0 +1,301 @@
import { useMemo, useState } from "react";
import { ShieldCheck, RefreshCw, Plus, X, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/button";
import { managerPost } from "@/main-axios";
import {
useManagerData,
useManagerAction,
extractError,
} from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
interface CertInfo {
client: string;
name: string;
domains: string[];
expiry: string | null;
}
interface SslData {
clients: { certbot: boolean; acmeSh: boolean };
certs: CertInfo[];
}
type Challenge = "http-standalone" | "http-webroot" | "dns";
/** Days until the given expiry string, or null if unparseable. */
function daysUntil(expiry: string | null): number | null {
if (!expiry) return null;
const ts = Date.parse(expiry);
if (Number.isNaN(ts)) return null;
return Math.round((ts - Date.now()) / 86400000);
}
function ExpiryBadge({ expiry }: { expiry: string | null }) {
const { t } = useTranslation();
const days = daysUntil(expiry);
if (days == null)
return <span className="text-muted-foreground">{expiry ?? "—"}</span>;
const tone =
days < 0
? "text-destructive"
: days < 14
? "text-yellow-500"
: "text-accent-brand";
return (
<span className={tone} title={expiry ?? undefined}>
{days < 0
? t("hostMetrics.managers.sslExpired")
: t("hostMetrics.managers.sslInDays", { days })}
</span>
);
}
export function SslManagerCard({ hostId }: { hostId: number | null }) {
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<SslData>(
hostId,
"ssl",
);
const { busy, run } = useManagerAction(hostId);
const [showIssue, setShowIssue] = useState(false);
const [domains, setDomains] = useState("");
const [challenge, setChallenge] = useState<Challenge>("http-standalone");
const [webroot, setWebroot] = useState("");
const [dnsProvider, setDnsProvider] = useState("");
const [issuing, setIssuing] = useState(false);
const clients = data?.clients;
const activeClient: "certbot" | "acme.sh" | null = clients?.certbot
? "certbot"
: clients?.acmeSh
? "acme.sh"
: null;
const certs = useMemo(() => data?.certs ?? [], [data?.certs]);
const renew = (dryRun: boolean) => {
if (!activeClient) return;
run(
"ssl",
{ client: activeClient, dryRun },
{
action: "renew",
toastId: "ssl-op",
loadingMsg: t("hostMetrics.managers.working"),
successMsg: t("hostMetrics.managers.actionDone", { name: "renew" }),
failMsg: t("hostMetrics.managers.actionFailed"),
onDone: refresh,
},
);
};
const revoke = (cert: CertInfo) => {
if (cert.client !== "certbot" && cert.client !== "acme.sh") return;
if (
!window.confirm(
t("hostMetrics.managers.sslRevokeConfirm", { name: cert.name }),
)
)
return;
run(
"ssl",
{ client: cert.client, name: cert.name },
{
action: "revoke",
toastId: "ssl-op",
loadingMsg: t("hostMetrics.managers.working"),
successMsg: t("hostMetrics.managers.sslRevoked"),
failMsg: t("hostMetrics.managers.actionFailed"),
onDone: refresh,
},
);
};
const issue = async () => {
if (hostId == null || !activeClient) return;
const list = domains
.split(/[\s,]+/)
.map((d) => d.trim())
.filter(Boolean);
if (list.length === 0) {
const { toast } = await import("sonner");
toast.error(t("hostMetrics.managers.sslNeedDomain"));
return;
}
setIssuing(true);
const { toast } = await import("sonner");
toast.loading(t("hostMetrics.managers.working"), { id: "ssl-issue" });
try {
const res = await managerPost<{ success: boolean; output: string }>(
hostId,
"ssl",
{
client: activeClient,
domains: list,
challenge,
webroot: challenge === "http-webroot" ? webroot : undefined,
dnsProvider: challenge === "dns" ? dnsProvider : undefined,
},
"issue",
);
toast[res.success ? "success" : "error"](
res.success
? t("hostMetrics.managers.sslIssued")
: t("hostMetrics.managers.actionFailed"),
{ id: "ssl-issue", description: res.output?.slice(-200) },
);
if (res.success) {
setShowIssue(false);
setDomains("");
refresh();
}
} catch (e) {
toast.error(extractError(e).message, { id: "ssl-issue" });
} finally {
setIssuing(false);
}
};
return (
<ManagerCardShell
title={t("hostMetrics.managers.ssl")}
icon={<ShieldCheck className="size-3.5" />}
loading={loading}
error={error}
onRefresh={refresh}
empty={!loading && certs.length === 0 && !activeClient}
emptyMessage={t("hostMetrics.managers.noAcmeClient")}
headerExtra={
activeClient ? (
<>
<Button
variant="ghost"
size="xs"
disabled={busy}
onClick={() => renew(true)}
>
{t("hostMetrics.managers.dryRun")}
</Button>
<Button
variant="outline"
size="xs"
disabled={busy}
onClick={() => renew(false)}
>
<RefreshCw className="size-3" />
{t("hostMetrics.managers.renew")}
</Button>
</>
) : undefined
}
>
<div className="mb-2 flex items-center justify-between text-[10px] uppercase tracking-widest text-muted-foreground">
<div className="flex items-center gap-2">
<span>{t("hostMetrics.managers.clients")}:</span>
<span className={clients?.certbot ? "text-accent-brand" : ""}>
certbot
</span>
<span className={clients?.acmeSh ? "text-accent-brand" : ""}>
acme.sh
</span>
</div>
{activeClient && (
<Button
variant="ghost"
size="xs"
onClick={() => setShowIssue((v) => !v)}
>
{showIssue ? <X className="size-3" /> : <Plus className="size-3" />}
{t("hostMetrics.managers.sslIssueCert")}
</Button>
)}
</div>
{showIssue && activeClient && (
<div className="mb-3 flex flex-col gap-2 border border-dashed border-border p-2">
<input
value={domains}
onChange={(e) => setDomains(e.target.value)}
placeholder={t("hostMetrics.managers.sslDomainsPlaceholder")}
className="h-7 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
/>
<div className="flex items-center gap-1.5">
<select
value={challenge}
onChange={(e) => setChallenge(e.target.value as Challenge)}
className="h-7 flex-1 border border-border bg-background px-1 text-xs"
>
<option value="http-standalone">
{t("hostMetrics.managers.sslHttpStandalone")}
</option>
<option value="http-webroot">
{t("hostMetrics.managers.sslHttpWebroot")}
</option>
<option value="dns">{t("hostMetrics.managers.sslDns")}</option>
</select>
</div>
{challenge === "http-webroot" && (
<input
value={webroot}
onChange={(e) => setWebroot(e.target.value)}
placeholder="/var/www/html"
className="h-7 w-full border border-border bg-background px-2 font-mono text-xs outline-none focus:ring-1 focus:ring-ring"
/>
)}
{challenge === "dns" && (
<input
value={dnsProvider}
onChange={(e) => setDnsProvider(e.target.value)}
placeholder={t("hostMetrics.managers.sslDnsProvider")}
className="h-7 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
/>
)}
<Button
variant="outline"
size="sm"
disabled={issuing}
onClick={issue}
className="self-start"
>
{t("hostMetrics.managers.sslIssueCert")}
</Button>
<span className="text-[10px] text-muted-foreground/70">
{t("hostMetrics.managers.sslIssueHint")}
</span>
</div>
)}
<div className="flex flex-col divide-y divide-border">
{certs.map((c) => (
<div
key={`${c.client}-${c.name}`}
className="flex items-center justify-between gap-2 py-1.5 text-xs"
>
<span
className="truncate font-semibold"
title={c.domains.join(", ")}
>
{c.name}
</span>
<div className="flex shrink-0 items-center gap-2">
<span className="font-mono text-[11px]">
<ExpiryBadge expiry={c.expiry} />
</span>
{(c.client === "certbot" || c.client === "acme.sh") && (
<button
onClick={() => revoke(c)}
disabled={busy}
title={t("hostMetrics.managers.sslRevoke")}
className="text-muted-foreground transition-colors hover:text-destructive disabled:opacity-40"
>
<Trash2 className="size-3.5" />
</button>
)}
</div>
</div>
))}
</div>
</ManagerCardShell>
);
}
@@ -0,0 +1,145 @@
import { useMemo, useState } from "react";
import { Users, Plus, Trash2, ShieldCheck, Shield } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { managerPost } from "@/main-axios";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { ManagerSearch } from "./ManagerToolbar";
interface SystemUser {
name: string;
uid: number;
shell: string;
}
interface UsersData {
users: SystemUser[];
sudoers: string[];
}
export function UserManagerCard({ hostId }: { hostId: number | null }) {
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<UsersData>(
hostId,
"users",
);
const [newUser, setNewUser] = useState("");
const [filter, setFilter] = useState("");
const [busy, setBusy] = useState<string | null>(null);
const sudoers = new Set(data?.sudoers ?? []);
const users = useMemo(
() =>
(data?.users ?? []).filter(
(u) => !filter || u.name.toLowerCase().includes(filter.toLowerCase()),
),
[data?.users, filter],
);
const action = async (act: string, username: string, group?: string) => {
if (hostId == null) return;
setBusy(username);
try {
const res = await managerPost<{ success: boolean; output: string }>(
hostId,
"users",
{ action: act, username, group },
"action",
);
if (res.success) {
toast.success(t("hostMetrics.managers.actionDone", { name: username }));
if (act === "create") setNewUser("");
refresh();
} else {
toast.error(res.output || t("hostMetrics.managers.actionFailed"));
}
} catch (e) {
toast.error(extractError(e).message);
} finally {
setBusy(null);
}
};
return (
<ManagerCardShell
title={t("hostMetrics.managers.users")}
icon={<Users className="size-3.5" />}
loading={loading}
error={error}
onRefresh={refresh}
>
<div className="mb-2 flex items-center gap-1.5">
<input
value={newUser}
onChange={(e) => setNewUser(e.target.value)}
placeholder={t("hostMetrics.managers.newUsername")}
className="h-7 flex-1 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
/>
<Button
variant="outline"
size="xs"
disabled={!newUser || busy === newUser}
onClick={() => action("create", newUser)}
>
<Plus className="size-3" />
{t("hostMetrics.managers.addUser")}
</Button>
</div>
<ManagerSearch value={filter} onChange={setFilter} count={users.length} />
<div className="flex flex-col">
{users.map((u) => {
const isSudoer = sudoers.has(u.name);
return (
<div
key={u.name}
className="flex items-center justify-between gap-2 border-b border-border/50 py-1.5 last:border-0"
>
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-xs font-semibold">{u.name}</span>
{isSudoer && (
<ShieldCheck className="size-3 shrink-0 text-accent-brand" />
)}
<span className="truncate font-mono text-[10px] text-muted-foreground">
{u.uid}
</span>
</div>
<div className="flex shrink-0 items-center gap-1">
<button
onClick={() =>
action(
isSudoer ? "removeFromGroup" : "addToGroup",
u.name,
"sudo",
)
}
disabled={busy === u.name}
title={
isSudoer
? t("hostMetrics.managers.revokeSudo")
: t("hostMetrics.managers.grantSudo")
}
className={`disabled:opacity-40 ${
isSudoer
? "text-accent-brand hover:text-muted-foreground"
: "text-muted-foreground hover:text-accent-brand"
}`}
>
<Shield className="size-3.5" />
</button>
<button
onClick={() => action("delete", u.name)}
disabled={busy === u.name}
title={t("hostMetrics.managers.deleteUser")}
className="text-muted-foreground hover:text-destructive disabled:opacity-40"
>
<Trash2 className="size-3.5" />
</button>
</div>
</div>
);
})}
</div>
</ManagerCardShell>
);
}
@@ -0,0 +1,119 @@
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { managerGet, managerPost } from "@/main-axios";
interface ManagerError {
message: string;
code?: string;
}
function extractError(err: unknown): ManagerError {
const e = err as {
response?: { data?: { error?: string; code?: string } };
message?: string;
};
return {
message: e?.response?.data?.error || e?.message || "Request failed",
code: e?.response?.data?.code,
};
}
/**
* Fetch a manager read resource on mount + manual refresh, with loading/error
* state. `hostId` null disables fetching.
*/
export function useManagerData<T>(
hostId: number | null,
resource: string,
params?: Record<string, string | number>,
) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<ManagerError | null>(null);
const paramsKey = params ? JSON.stringify(params) : "";
const refresh = useCallback(async () => {
if (hostId == null) return;
setLoading(true);
setError(null);
try {
const res = await managerGet<T>(hostId, resource, params);
setData(res);
} catch (err) {
setError(extractError(err));
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hostId, resource, paramsKey]);
useEffect(() => {
refresh();
}, [refresh]);
return { data, loading, error, refresh, setData };
}
interface ActionResult {
success: boolean;
output?: string;
}
/**
* Run a manager POST action with consistent toast feedback (loading -> success
* /error) and a busy flag. On success the optional `onDone` callback runs (e.g.
* to refresh the card). Returns the result so callers can branch further.
*/
export function useManagerAction(hostId: number | null) {
const [busy, setBusy] = useState(false);
const run = useCallback(
async <T extends ActionResult>(
resource: string,
body: unknown,
opts: {
action?: string;
toastId?: string;
loadingMsg?: string;
successMsg?: string;
failMsg?: string;
onDone?: () => void;
} = {},
): Promise<T | null> => {
if (hostId == null) return null;
const id = opts.toastId ?? `${resource}-action`;
setBusy(true);
if (opts.loadingMsg) toast.loading(opts.loadingMsg, { id });
try {
const res = await managerPost<T>(hostId, resource, body, opts.action);
if (res.success) {
if (opts.successMsg)
toast.success(opts.successMsg, {
id,
description: res.output?.slice(-200),
});
else toast.dismiss(id);
opts.onDone?.();
} else {
toast.error(opts.failMsg ?? res.output ?? "Action failed", {
id,
description: res.output?.slice(-200),
});
}
return res;
} catch (err) {
toast.error(extractError(err).message, { id });
return null;
} finally {
setBusy(false);
}
},
[hostId],
);
return { busy, run };
}
export { extractError };
export type { ManagerError };
@@ -0,0 +1,91 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getHostMetricsLayout, saveHostMetricsLayout } from "@/main-axios";
import type { HostMetricsLayout } from "@/types/host-metrics";
const SAVE_DEBOUNCE_MS = 800;
function lsKey(hostId: number) {
return `hostMetricsLayout:${hostId}`;
}
function readCache(hostId: number): HostMetricsLayout | null {
try {
const raw = localStorage.getItem(lsKey(hostId));
if (!raw) return null;
const parsed = JSON.parse(raw);
if (parsed && Array.isArray(parsed.slots))
return parsed as HostMetricsLayout;
} catch {
/* ignore */
}
return null;
}
function writeCache(hostId: number, layout: HostMetricsLayout) {
try {
localStorage.setItem(lsKey(hostId), JSON.stringify(layout));
} catch {
/* ignore */
}
}
/**
* Per-host Host Metrics layout, cached in localStorage for instant paint and
* synced to the backend (so it follows the user across devices). Saves are
* debounced. Returns null layout until the first load resolves.
*/
export function useHostMetricsPreferences(hostId: number | null) {
const [layout, setLayout] = useState<HostMetricsLayout | null>(() =>
hostId != null ? readCache(hostId) : null,
);
const [loaded, setLoaded] = useState(false);
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (hostId == null) return;
let cancelled = false;
setLoaded(false);
const cached = readCache(hostId);
if (cached) setLayout(cached);
getHostMetricsLayout(hostId)
.then((remote) => {
if (cancelled || !remote) return;
setLayout(remote);
writeCache(hostId, remote);
})
.catch(() => {
/* keep cache/default */
})
.finally(() => {
if (!cancelled) setLoaded(true);
});
return () => {
cancelled = true;
};
}, [hostId]);
const save = useCallback(
(next: HostMetricsLayout) => {
if (hostId == null) return;
setLayout(next);
writeCache(hostId, next);
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
saveHostMetricsLayout(hostId, next).catch(() => {
/* best-effort; cache already holds it */
});
}, SAVE_DEBOUNCE_MS);
},
[hostId],
);
useEffect(() => {
return () => {
if (saveTimer.current) clearTimeout(saveTimer.current);
};
}, []);
return { layout, setLayout: save, loaded };
}
@@ -1,107 +0,0 @@
import React from "react";
import { Cpu } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/main-axios.ts";
import { SectionCard } from "@/components/section-card";
interface CpuWidgetProps {
metrics: ServerMetrics | null;
metricsHistory: ServerMetrics[];
}
function Sparkline({
history,
current,
}: {
history: ServerMetrics[];
current: number | null;
}) {
const points = [
...history.map((m) => m?.cpu?.percent ?? 0),
current ?? 0,
].slice(-20);
const w = 300;
const h = 48;
const hasData = points.length >= 2;
const max = hasData ? Math.max(...points, 1) : 1;
const coords = hasData
? points.map((v, i) => {
const x = (i / (points.length - 1)) * w;
const y = h - (v / max) * h;
return `${x},${y}`;
})
: [];
const d = hasData ? `M ${coords.join(" L ")}` : "";
const fill = hasData ? `M 0,${h} L ${coords.join(" L ")} L ${w},${h} Z` : "";
return (
<div className="h-12 md:h-16 w-full mt-2 bg-muted/20 border border-border/50 relative overflow-hidden">
{hasData && (
<svg
className="absolute inset-0 h-full w-full"
viewBox={`0 0 ${w} ${h}`}
preserveAspectRatio="none"
>
<path d={fill} fill="currentColor" className="text-accent-brand/10" />
<path
d={d}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="text-accent-brand/60"
/>
</svg>
)}
</div>
);
}
export function CpuWidget({ metrics, metricsHistory }: CpuWidgetProps) {
const { t } = useTranslation();
const percent = metrics?.cpu?.percent ?? null;
const cores = metrics?.cpu?.cores ?? null;
const load = metrics?.cpu?.load ?? null;
return (
<SectionCard
title={t("serverStats.cpuUsage")}
icon={<Cpu className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-2">
<div className="flex items-end justify-between">
<div className="flex flex-col">
<span className="text-xl md:text-3xl font-bold text-accent-brand">
{percent !== null ? `${percent.toFixed(1)}%` : "N/A"}
</span>
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-bold">
{cores !== null
? t("serverStats.cpuCores", { count: cores })
: t("serverStats.naCpus")}
</span>
</div>
{load && (
<div className="text-right">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-bold">
{t("serverStats.loadAvg")}
</span>
<div className="text-xs font-mono">
{load[0].toFixed(2)}&nbsp;&nbsp;{load[1].toFixed(2)}&nbsp;&nbsp;
{load[2].toFixed(2)}
</div>
</div>
)}
</div>
<div className="h-2 bg-muted w-full overflow-hidden">
<div
className="h-full bg-accent-brand transition-all duration-500"
style={{ width: `${percent ?? 0}%` }}
/>
</div>
<Sparkline history={metricsHistory} current={percent} />
</div>
</SectionCard>
);
}
@@ -1,102 +0,0 @@
import { HardDrive } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/main-axios.ts";
import { SectionCard } from "@/components/section-card";
interface DiskWidgetProps {
metrics: ServerMetrics | null;
metricsHistory: ServerMetrics[];
}
function Sparkline({
history,
current,
}: {
history: ServerMetrics[];
current: number | null;
}) {
const points = [
...history.map((m) => m?.disk?.percent ?? 0),
current ?? 0,
].slice(-20);
const w = 300;
const h = 48;
const hasData = points.length >= 2;
const max = hasData ? Math.max(...points, 1) : 1;
const coords = hasData
? points.map((v, i) => {
const x = (i / (points.length - 1)) * w;
const y = h - (v / max) * h;
return `${x},${y}`;
})
: [];
const d = hasData ? `M ${coords.join(" L ")}` : "";
const fill = hasData ? `M 0,${h} L ${coords.join(" L ")} L ${w},${h} Z` : "";
return (
<div className="h-12 md:h-16 w-full mt-2 bg-muted/20 border border-border/50 relative overflow-hidden">
{hasData && (
<svg
className="absolute inset-0 h-full w-full"
viewBox={`0 0 ${w} ${h}`}
preserveAspectRatio="none"
>
<path d={fill} fill="currentColor" className="text-accent-brand/10" />
<path
d={d}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="text-accent-brand/60"
/>
</svg>
)}
</div>
);
}
export function DiskWidget({ metrics, metricsHistory }: DiskWidgetProps) {
const { t } = useTranslation();
const percent = metrics?.disk?.percent ?? null;
const usedHuman = metrics?.disk?.usedHuman ?? null;
const totalHuman = metrics?.disk?.totalHuman ?? null;
const availableHuman = metrics?.disk?.availableHuman ?? null;
return (
<SectionCard
title={t("serverStats.diskUsage")}
icon={<HardDrive className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-2">
<div className="flex items-end justify-between">
<div className="flex flex-col">
<span className="text-xl md:text-3xl font-bold text-accent-brand">
{percent !== null ? `${percent}%` : "N/A"}
</span>
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-bold">
{usedHuman && totalHuman ? `${usedHuman} / ${totalHuman}` : "N/A"}
</span>
</div>
</div>
<div className="h-2 bg-muted w-full overflow-hidden">
<div
className="h-full bg-accent-brand transition-all duration-500"
style={{ width: `${percent ?? 0}%` }}
/>
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground font-semibold">
{t("serverStats.available")}
</span>
<span className="font-mono">{availableHuman ?? "N/A"}</span>
</div>
</div>
<Sparkline history={metricsHistory} current={percent} />
</div>
</SectionCard>
);
}
@@ -1,148 +0,0 @@
import React from "react";
import { Shield, ShieldOff, ShieldCheck, ChevronDown } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/main-axios.ts";
import type {
FirewallMetrics,
FirewallChain,
FirewallRule,
} from "@/types/stats-widgets";
import { SectionCard } from "@/components/section-card";
interface FirewallWidgetProps {
metrics: ServerMetrics | null;
metricsHistory: ServerMetrics[];
}
function RuleRow({ rule }: { rule: FirewallRule }) {
const { t } = useTranslation();
const targetClass =
rule.target.toUpperCase() === "ACCEPT"
? "text-accent-brand"
: rule.target.toUpperCase() === "DROP"
? "text-destructive"
: rule.target.toUpperCase() === "REJECT"
? "text-yellow-500"
: "text-muted-foreground";
const src =
rule.interface ??
rule.state ??
(rule.source === "0.0.0.0/0"
? t("serverStats.firewall.anywhere")
: rule.source);
return (
<div className="grid grid-cols-4 gap-2 text-xs font-mono py-1 border-b border-border/50 last:border-0">
<span className={`font-bold ${targetClass}`}>{rule.target}</span>
<span className="text-muted-foreground">
{rule.protocol.toUpperCase()}
</span>
<span>{rule.dport ?? "—"}</span>
<span className="truncate text-muted-foreground" title={src}>
{src}
</span>
</div>
);
}
function ChainSection({ chain }: { chain: FirewallChain }) {
const { t } = useTranslation();
const [open, setOpen] = React.useState(true);
const policyClass =
chain.policy.toUpperCase() === "ACCEPT"
? "text-accent-brand"
: chain.policy.toUpperCase() === "DROP"
? "text-destructive"
: "text-yellow-500";
return (
<div>
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex items-center gap-2 w-full py-1.5 hover:bg-muted/30 text-left"
>
<ChevronDown
className={`size-3 text-muted-foreground transition-transform ${open ? "" : "-rotate-90"}`}
/>
<span className="text-xs font-bold">{chain.name}</span>
<span className="text-[10px] text-muted-foreground">
({t("serverStats.firewall.policy")}:{" "}
<span className={policyClass}>{chain.policy}</span>)
</span>
<span className="text-[10px] text-muted-foreground ml-auto">
{chain.rules.length} {t("serverStats.firewall.rules")}
</span>
</button>
{open && chain.rules.length > 0 && (
<div className="ml-5">
<div className="grid grid-cols-4 gap-2 text-[10px] text-muted-foreground font-bold uppercase pb-1 border-b border-border">
<span>{t("serverStats.firewall.action")}</span>
<span>{t("serverStats.firewall.protocol")}</span>
<span>{t("serverStats.firewall.port")}</span>
<span>{t("serverStats.firewall.source")}</span>
</div>
{chain.rules.map((rule, i) => (
<RuleRow key={i} rule={rule} />
))}
</div>
)}
</div>
);
}
export function FirewallWidget({ metrics }: FirewallWidgetProps) {
const { t } = useTranslation();
const firewall = (metrics as ServerMetrics & { firewall?: FirewallMetrics })
?.firewall;
const statusIcon =
!firewall || firewall.type === "none" ? (
<ShieldOff className="size-3.5 text-muted-foreground" />
) : firewall.status === "active" ? (
<ShieldCheck className="size-3.5 text-accent-brand" />
) : (
<Shield className="size-3.5 text-yellow-500" />
);
const statusBadge =
firewall?.status === "active" ? (
<span className="flex items-center gap-1.5 px-2 py-0.5 border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-[10px] font-bold">
<ShieldCheck className="size-3" /> ACTIVE
</span>
) : (
<span className="flex items-center gap-1.5 px-2 py-0.5 border border-border text-muted-foreground text-[10px] font-bold">
{t("serverStats.firewall.inactive").toUpperCase()}
</span>
);
return (
<SectionCard title={t("serverStats.firewall.title")} icon={statusIcon}>
<div className="flex flex-col gap-3 py-1">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold">
{t("serverStats.firewall.title")}
</span>
{statusBadge}
</div>
{firewall?.type && firewall.type !== "none" && (
<span className="text-[10px] text-muted-foreground uppercase font-bold">
{firewall.type}
</span>
)}
{firewall && firewall.chains.length > 0 ? (
<div className="flex flex-col gap-1 overflow-y-auto max-h-[320px]">
{firewall.chains.map((chain) => (
<ChainSection key={chain.name} chain={chain} />
))}
</div>
) : (
<span className="text-xs text-muted-foreground">
{t("serverStats.firewall.noData")}
</span>
)}
</div>
</SectionCard>
);
}
@@ -1,89 +0,0 @@
import { UserCheck, UserX } from "lucide-react";
import { useTranslation } from "react-i18next";
import { SectionCard } from "@/components/section-card";
interface LoginRecord {
user: string;
ip: string;
time: string;
status: "success" | "failed";
}
interface LoginStatsMetrics {
recentLogins: LoginRecord[];
failedLogins: LoginRecord[];
totalLogins: number;
uniqueIPs: number;
}
interface LoginStatsWidgetProps {
metrics: { login_stats?: LoginStatsMetrics } | null;
metricsHistory: unknown[];
}
export function LoginStatsWidget({ metrics }: LoginStatsWidgetProps) {
const { t } = useTranslation();
const loginStats = metrics?.login_stats;
const recentLogins = loginStats?.recentLogins ?? [];
const failedLogins = loginStats?.failedLogins ?? [];
const allLogins = [
...recentLogins.map((l) => ({ ...l, status: "success" as const })),
...failedLogins.map((l) => ({ ...l, status: "failed" as const })),
]
.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime())
.slice(0, 6);
return (
<SectionCard
title={t("serverStats.loginStats")}
icon={<UserCheck className="size-3.5" />}
>
<div className="flex flex-col gap-2 py-1">
{allLogins.length === 0 ? (
<span className="text-xs text-muted-foreground italic py-2">
{t("serverStats.noRecentLoginData")}
</span>
) : (
<div className="flex flex-col gap-2 overflow-y-auto max-h-[300px]">
{allLogins.map((login, i) => (
<div
key={i}
className={`flex items-center justify-between p-2 border ${login.status === "success" ? "border-border bg-muted/30" : "border-destructive/30 bg-destructive/5"}`}
>
<div className="flex flex-col">
<div className="flex items-center gap-1.5">
{login.status === "failed" ? (
<UserX className="size-3 text-destructive" />
) : (
<UserCheck className="size-3 text-accent-brand" />
)}
<span
className={`text-xs font-bold ${login.status === "failed" ? "text-destructive" : ""}`}
>
{login.user}
</span>
</div>
<span className="text-[10px] text-muted-foreground font-mono">
{login.ip}
</span>
</div>
<div className="flex flex-col items-end gap-1">
<span
className={`text-[9px] font-bold uppercase px-1.5 py-px border ${login.status === "success" ? "border-accent-brand/40 text-accent-brand bg-accent-brand/10" : "border-destructive/40 text-destructive"}`}
>
{login.status}
</span>
<span className="text-[10px] text-muted-foreground">
{new Date(login.time).toLocaleTimeString()}
</span>
</div>
</div>
))}
</div>
)}
</div>
</SectionCard>
);
}
@@ -1,113 +0,0 @@
import { MemoryStick } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/main-axios.ts";
import { SectionCard } from "@/components/section-card";
interface MemoryWidgetProps {
metrics: ServerMetrics | null;
metricsHistory: ServerMetrics[];
}
function Sparkline({
history,
current,
}: {
history: ServerMetrics[];
current: number | null;
}) {
const points = [
...history.map((m) => m?.memory?.percent ?? 0),
current ?? 0,
].slice(-20);
const w = 300;
const h = 48;
const hasData = points.length >= 2;
const max = hasData ? Math.max(...points, 1) : 1;
const coords = hasData
? points.map((v, i) => {
const x = (i / (points.length - 1)) * w;
const y = h - (v / max) * h;
return `${x},${y}`;
})
: [];
const d = hasData ? `M ${coords.join(" L ")}` : "";
const fill = hasData ? `M 0,${h} L ${coords.join(" L ")} L ${w},${h} Z` : "";
return (
<div className="h-12 md:h-16 w-full mt-2 bg-muted/20 border border-border/50 relative overflow-hidden">
{hasData && (
<svg
className="absolute inset-0 h-full w-full"
viewBox={`0 0 ${w} ${h}`}
preserveAspectRatio="none"
>
<path d={fill} fill="currentColor" className="text-accent-brand/10" />
<path
d={d}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="text-accent-brand/60"
/>
</svg>
)}
</div>
);
}
export function MemoryWidget({ metrics, metricsHistory }: MemoryWidgetProps) {
const { t } = useTranslation();
const percent = metrics?.memory?.percent ?? null;
const usedGiB = metrics?.memory?.usedGiB ?? null;
const totalGiB = metrics?.memory?.totalGiB ?? null;
return (
<SectionCard
title={t("serverStats.memoryUsage")}
icon={<MemoryStick className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-2">
<div className="flex items-end justify-between">
<div className="flex flex-col">
<span className="text-xl md:text-3xl font-bold text-accent-brand">
{percent !== null ? `${percent.toFixed(1)}%` : "N/A"}
</span>
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-bold">
{usedGiB !== null && totalGiB !== null
? `${usedGiB.toFixed(1)} / ${totalGiB.toFixed(1)} GiB`
: "N/A"}
</span>
</div>
</div>
<div className="h-2 bg-muted w-full overflow-hidden">
<div
className="h-full bg-accent-brand transition-all duration-500"
style={{ width: `${percent ?? 0}%` }}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col p-2 bg-muted/30 border border-border">
<span className="text-[10px] text-muted-foreground uppercase font-bold">
{t("serverStats.swap")}
</span>
<span className="text-xs font-semibold">N/A</span>
</div>
<div className="flex flex-col p-2 bg-muted/30 border border-border">
<span className="text-[10px] text-muted-foreground uppercase font-bold">
{t("serverStats.free")}
</span>
<span className="text-xs font-semibold">
{usedGiB !== null && totalGiB !== null
? `${(totalGiB - usedGiB).toFixed(1)} GiB`
: "N/A"}
</span>
</div>
</div>
<Sparkline history={metricsHistory} current={percent} />
</div>
</SectionCard>
);
}
@@ -1,86 +0,0 @@
import { Cable, Container, Network, Wifi, WifiOff } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/main-axios.ts";
import { SectionCard } from "@/components/section-card";
interface NetworkWidgetProps {
metrics: ServerMetrics | null;
metricsHistory: ServerMetrics[];
}
export function NetworkWidget({ metrics }: NetworkWidgetProps) {
const { t } = useTranslation();
const metricsWithNetwork = metrics as ServerMetrics & {
network?: {
interfaces?: Array<{
name: string;
state: string;
ip: string;
rx?: string;
tx?: string;
}>;
};
};
const interfaces = metricsWithNetwork?.network?.interfaces ?? [];
const ifaceIcon = (name: string) => {
if (/^(wl|wlan|wifi|ath)/.test(name))
return <Wifi className="size-3 text-muted-foreground/50" />;
if (/^(eth|en|enp|eno|ens)/.test(name))
return <Cable className="size-3 text-muted-foreground/50" />;
if (/^(docker|br-|veth|virbr|vlan|bond|tun|tap|wg|lo)/.test(name))
return <Container className="size-3 text-muted-foreground/50" />;
return <Network className="size-3 text-muted-foreground/50" />;
};
return (
<SectionCard
title={t("serverStats.networkInterfaces")}
icon={<Network className="size-3.5" />}
>
<div className="flex flex-col gap-2 py-1">
{interfaces.length === 0 ? (
<div className="flex flex-col items-center justify-center py-6 text-muted-foreground gap-2">
<WifiOff className="size-6 opacity-40" />
<span className="text-xs">
{t("serverStats.noInterfacesFound")}
</span>
</div>
) : (
<div className="flex flex-col gap-2 overflow-y-auto max-h-[260px]">
{interfaces.map((iface, i) => (
<div
key={i}
className="flex flex-col p-2 border border-border bg-muted/30 gap-1"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{ifaceIcon(iface.name)}
<div
className={`size-1.5 rounded-full ${iface.state === "UP" ? "bg-accent-brand" : "bg-muted-foreground"}`}
/>
<span className="text-sm font-bold font-mono">
{iface.name}
</span>
</div>
<span className="text-[10px] font-semibold px-1.5 py-px border border-border text-muted-foreground uppercase">
{iface.state}
</span>
</div>
<div className="flex justify-between text-[10px] font-mono text-muted-foreground">
<span>{iface.ip}</span>
{(iface.rx || iface.tx) && (
<span>
{iface.rx ?? "—"} / {iface.tx ?? "—"}
</span>
)}
</div>
</div>
))}
</div>
)}
</div>
</SectionCard>
);
}
@@ -1,70 +0,0 @@
import { Unplug } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/main-axios.ts";
import type { PortsMetrics, ListeningPort } from "@/types/stats-widgets";
import { SectionCard } from "@/components/section-card";
interface PortsWidgetProps {
metrics: ServerMetrics | null;
metricsHistory: ServerMetrics[];
}
function PortRow({ port }: { port: ListeningPort }) {
const formatAddress = (addr: string) =>
addr === "0.0.0.0" || addr === "*" || addr === "::" ? "*" : addr;
return (
<div className="grid grid-cols-4 text-xs font-mono py-1 border-b border-border/50 last:border-0 min-w-0 overflow-hidden">
<span className="text-accent-brand font-bold truncate">
{port.localPort}
</span>
<span className="text-muted-foreground truncate">
{port.protocol.toUpperCase()}
</span>
<span className="font-semibold truncate">
{port.process ?? (port.pid ? `PID:${port.pid}` : "—")}
</span>
<span className="text-right text-muted-foreground truncate">
{formatAddress(port.localAddress)}
</span>
</div>
);
}
export function PortsWidget({ metrics }: PortsWidgetProps) {
const { t } = useTranslation();
const portsData = (metrics as ServerMetrics & { ports?: PortsMetrics })
?.ports;
const ports = portsData?.ports ?? [];
return (
<SectionCard
title={t("serverStats.ports.title")}
icon={<Unplug className="size-3.5" />}
>
<div className="flex flex-col gap-1.5 py-1 overflow-x-hidden">
<div className="grid grid-cols-4 text-[10px] text-muted-foreground font-bold uppercase pb-1 border-b border-border min-w-0">
<span>{t("serverStats.ports.port")}</span>
<span>{t("serverStats.ports.protocol")}</span>
<span>{t("serverStats.ports.process")}</span>
<span className="text-right">{t("serverStats.ports.address")}</span>
</div>
{ports.length === 0 ? (
<span className="text-xs text-muted-foreground italic py-2">
{t("serverStats.ports.noData")}
</span>
) : (
<div className="overflow-y-auto max-h-[300px]">
{ports.map((port, i) => (
<PortRow
key={`${port.protocol}-${port.localPort}-${i}`}
port={port}
/>
))}
</div>
)}
</div>
</SectionCard>
);
}
@@ -1,67 +0,0 @@
import { List, Activity } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/main-axios.ts";
import { SectionCard } from "@/components/section-card";
interface ProcessesWidgetProps {
metrics: ServerMetrics | null;
metricsHistory: ServerMetrics[];
}
export function ProcessesWidget({ metrics }: ProcessesWidgetProps) {
const { t } = useTranslation();
const metricsWithProcesses = metrics as ServerMetrics & {
processes?: {
total?: number;
running?: number;
top?: Array<{
pid: number;
cpu: number;
mem: number;
command: string;
user: string;
}>;
};
};
const processes = metricsWithProcesses?.processes;
const topProcesses = processes?.top ?? [];
return (
<SectionCard
title={t("serverStats.processes")}
icon={<List className="size-3.5" />}
>
<div className="flex flex-col gap-1.5 py-1">
<div className="grid grid-cols-4 text-[10px] text-muted-foreground font-bold uppercase tracking-wider pb-1 border-b border-border min-w-0">
<span>PID</span>
<span>CPU</span>
<span>MEM</span>
<span>CMD</span>
</div>
{topProcesses.length === 0 ? (
<div className="flex flex-col items-center justify-center py-6 text-muted-foreground gap-2">
<Activity className="size-6 opacity-40" />
<span className="text-xs">{t("serverStats.noProcessesFound")}</span>
</div>
) : (
<div className="overflow-y-auto max-h-[280px]">
{topProcesses.map((proc, i) => (
<div
key={i}
className="grid grid-cols-4 text-xs font-mono py-1 border-b border-border/50 last:border-0 min-w-0"
>
<span className="text-muted-foreground">{proc.pid}</span>
<span className="text-accent-brand font-bold">{proc.cpu}%</span>
<span>{proc.mem}%</span>
<span className="truncate font-semibold" title={proc.command}>
{proc.command.split("/").pop()}
</span>
</div>
))}
</div>
)}
</div>
</SectionCard>
);
}
@@ -1,51 +0,0 @@
import { Server } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/main-axios.ts";
import { SectionCard } from "@/components/section-card";
interface SystemWidgetProps {
metrics: ServerMetrics | null;
metricsHistory: ServerMetrics[];
}
export function SystemWidget({ metrics }: SystemWidgetProps) {
const { t } = useTranslation();
const metricsWithSystem = metrics as ServerMetrics & {
system?: { hostname?: string; os?: string; kernel?: string; arch?: string };
uptime?: { formatted?: string };
};
const system = metricsWithSystem?.system;
const uptime = metricsWithSystem?.uptime;
const rows = [
{ label: t("serverStats.hostname"), value: system?.hostname },
{ label: t("serverStats.operatingSystem"), value: system?.os },
{ label: t("serverStats.kernel"), value: system?.kernel },
{ label: t("serverStats.architecture"), value: system?.arch },
{ label: t("serverStats.uptime"), value: uptime?.formatted },
].filter((r) => r.value);
return (
<SectionCard
title={t("serverStats.systemInfo")}
icon={<Server className="size-3.5" />}
>
<div className="grid grid-cols-1 gap-y-3 py-2">
{rows.map(({ label, value }) => (
<div key={label} className="flex flex-col">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-bold">
{label}
</span>
<span className="text-sm font-mono font-semibold truncate">
{value}
</span>
</div>
))}
{rows.length === 0 && (
<span className="text-xs text-muted-foreground">N/A</span>
)}
</div>
</SectionCard>
);
}
@@ -1,37 +0,0 @@
import { Clock } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/main-axios.ts";
import { SectionCard } from "@/components/section-card";
interface UptimeWidgetProps {
metrics: ServerMetrics | null;
metricsHistory: ServerMetrics[];
}
export function UptimeWidget({ metrics }: UptimeWidgetProps) {
const { t } = useTranslation();
const metricsWithUptime = metrics as ServerMetrics & {
uptime?: { formatted?: string; seconds?: number };
};
const uptime = metricsWithUptime?.uptime;
return (
<SectionCard
title={t("serverStats.uptime")}
icon={<Clock className="size-3.5" />}
>
<div className="flex flex-col gap-3 py-2">
<span className="text-xl md:text-3xl font-bold text-accent-brand">
{uptime?.formatted ?? "N/A"}
</span>
{uptime?.seconds && (
<span className="text-xs text-muted-foreground font-mono">
{Math.floor(uptime.seconds).toLocaleString()}{" "}
{t("serverStats.seconds")}
</span>
)}
</div>
</SectionCard>
);
}
@@ -1,10 +0,0 @@
export { CpuWidget } from "./CpuWidget.tsx";
export { MemoryWidget } from "./MemoryWidget.tsx";
export { DiskWidget } from "./DiskWidget.tsx";
export { NetworkWidget } from "./NetworkWidget.tsx";
export { UptimeWidget } from "./UptimeWidget.tsx";
export { ProcessesWidget } from "./ProcessesWidget.tsx";
export { SystemWidget } from "./SystemWidget.tsx";
export { LoginStatsWidget } from "./LoginStatsWidget.tsx";
export { PortsWidget } from "./PortsWidget.tsx";
export { FirewallWidget } from "./FirewallWidget.tsx";
+27 -56
View File
@@ -11,6 +11,7 @@ import { useXTerm } from "react-xtermjs";
import { FitAddon } from "@xterm/addon-fit";
import { ClipboardAddon } from "@xterm/addon-clipboard";
import { RobustClipboardProvider } from "@/lib/clipboard-provider";
import { copyToClipboard } from "@/lib/clipboard";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { useTranslation } from "react-i18next";
@@ -71,6 +72,8 @@ interface SSHTerminalProps {
onTitleChange?: (title: string) => void;
initialPath?: string;
executeCommand?: string;
/** Attach to this tmux session right after connecting (tmux monitor). */
tmuxAttachSession?: string;
onOpenFileManager?: (path?: string) => void;
previewTheme?: string | null;
}
@@ -84,6 +87,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
onClose,
initialPath,
executeCommand,
tmuxAttachSession,
onOpenFileManager,
previewTheme,
},
@@ -212,30 +216,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const totpTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const connectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const activityLoggedRef = useRef(false);
const [commandHistoryTrackingEnabled, setCommandHistoryTrackingEnabled] =
useState<boolean>(
() => localStorage.getItem("commandHistoryTracking") === "true",
);
useEffect(() => {
const handleCommandHistoryTrackingChanged = () => {
setCommandHistoryTrackingEnabled(
localStorage.getItem("commandHistoryTracking") === "true",
);
};
window.addEventListener(
"commandHistoryTrackingChanged",
handleCommandHistoryTrackingChanged,
);
return () => {
window.removeEventListener(
"commandHistoryTrackingChanged",
handleCommandHistoryTrackingChanged,
);
};
}, []);
const commandHistoryTrackingEnabled =
hostConfig.enableCommandHistory !== false;
const { trackInput, getCurrentCommand, updateCurrentCommand } =
useCommandTracker({
@@ -947,7 +929,14 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
ws.send(
JSON.stringify({
type: "connectToHost",
data: { cols, rows, hostConfig, initialPath, executeCommand },
data: {
cols,
rows,
hostConfig,
initialPath,
executeCommand,
tmuxAttachSession,
},
}),
);
}
@@ -982,10 +971,13 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
if (msg.type === "data") {
if (typeof msg.data === "string") {
const syntaxHighlightingEnabled =
localStorage.getItem("terminalSyntaxHighlighting") === "true";
hostConfig.terminalConfig?.syntaxHighlighting !== false;
const outputData = syntaxHighlightingEnabled
? highlightTerminalOutput(msg.data)
? highlightTerminalOutput(
msg.data,
hostConfig.terminalConfig?.syntaxHighlightingOptions,
)
: msg.data;
terminal.write(outputData);
@@ -1045,11 +1037,14 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}
} else {
const syntaxHighlightingEnabled =
localStorage.getItem("terminalSyntaxHighlighting") === "true";
hostConfig.terminalConfig?.syntaxHighlighting !== false;
const stringData = String(msg.data);
const outputData = syntaxHighlightingEnabled
? highlightTerminalOutput(stringData)
? highlightTerminalOutput(
stringData,
hostConfig.terminalConfig?.syntaxHighlightingOptions,
)
: stringData;
terminal.write(outputData);
@@ -1666,33 +1661,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}
async function writeTextToClipboard(text: string): Promise<boolean> {
try {
if (window.electronClipboard) {
await window.electronClipboard.writeText(text);
return true;
}
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
// fall through to legacy method
}
try {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
return true;
} catch {
toast.error(t("terminal.clipboardWriteFailed"));
return false;
}
const ok = await copyToClipboard(text);
if (!ok) toast.error(t("terminal.clipboardWriteFailed"));
return ok;
}
async function readTextFromClipboard(): Promise<string> {
+12 -1
View File
@@ -5,9 +5,17 @@ import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
interface TerminalAppProps {
hostId?: string;
/** tmux session to attach to once the shell is ready (tmux monitor "Attach"). */
tmuxSession?: string;
}
const TerminalApp: React.FC<TerminalAppProps> = ({ hostId }) => {
// Only the session name travels in the URL (never a raw command), so a crafted
// link cannot execute arbitrary input. `=` forces exact-name matching in tmux.
function tmuxAttachCommand(session: string): string {
return `tmux attach-session -t '=${session.replace(/'/g, "'\\''")}'`;
}
const TerminalApp: React.FC<TerminalAppProps> = ({ hostId, tmuxSession }) => {
const { t } = useTranslation();
return (
<FullScreenAppWrapper hostId={hostId}>
@@ -43,6 +51,9 @@ const TerminalApp: React.FC<TerminalAppProps> = ({ hostId }) => {
showTitle={false}
splitScreen={false}
onClose={() => {}}
executeCommand={
tmuxSession ? tmuxAttachCommand(tmuxSession) : undefined
}
/>
);
}}
@@ -0,0 +1,168 @@
// Interactive view of a tmux pane. Embeds the real SSH terminal (the same
// component as the Terminal feature) and attaches to the selected session via
// the native tmux_attach flow, so the pane is fully usable — typing, mouse
// scrolling and tmux copy-mode all behave exactly like a normal terminal tab.
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Activity,
Cpu,
MemoryStick,
RotateCw,
SquareSplitHorizontal,
SquareSplitVertical,
Trash2,
X,
} from "lucide-react";
import { CommandHistoryProvider } from "@/features/terminal/command-history/CommandHistoryContext";
import { Terminal } from "@/features/terminal/Terminal";
import type {
TerminalHandle,
TerminalHostConfig,
} from "@/features/terminal/Terminal";
import type { SSHHost } from "@/types/index";
import type { TmuxPaneMetrics } from "@/api/tmux-monitor-api";
import { formatMem } from "./format";
import type { SelectedPane } from "./types";
interface PanePreviewProps {
host: SSHHost;
pane: SelectedPane;
metrics?: TmuxPaneMetrics;
/** Imperative handle of the embedded terminal, used by the parent to nudge
* a refit/redraw after layout-changing tmux actions. */
terminalRef?: React.RefObject<TerminalHandle | null>;
/** Split the window containing this pane ("h" = new pane to the right,
* "v" = below tmux -h/-v semantics). */
onSplit: (direction: "h" | "v") => void;
/** Ask for confirmation and kill this pane. */
onKillPane: () => void;
onClose: () => void;
}
function newInstanceId(): string {
return typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
export function PanePreview({
host,
pane,
metrics,
terminalRef,
onSplit,
onKillPane,
onClose,
}: PanePreviewProps) {
const { t } = useTranslation();
// One PTY per mount; the parent remounts this component (via key) when the
// host or session changes. Pane switches within a session go through the
// focus endpoint instead, so the connection is reused.
const instanceIdRef = useRef<string>(newInstanceId());
// Bumping this remounts the embedded terminal with a fresh PTY — the rescue
// hatch when the attached client's rendering gets out of sync.
const [attachNonce, setAttachNonce] = useState(0);
function reattach() {
instanceIdRef.current = newInstanceId();
setAttachNonce((n) => n + 1);
}
return (
<>
<div className="flex items-center gap-3 border-b border-border px-3 py-1.5 text-xs text-muted-foreground">
<span className="font-medium text-foreground">
{pane.sessionName} · {pane.paneId}
</span>
{metrics && (
<>
<span className="flex items-center gap-1">
<Cpu className="size-3" />
{metrics.cpuPercent.toFixed(1)}%
</span>
<span className="flex items-center gap-1">
<MemoryStick className="size-3" />
{formatMem(metrics.memRssKb)}
</span>
{metrics.gpuMemMb > 0 && (
<span className="flex items-center gap-1">
<Activity className="size-3" />
{metrics.gpuMemMb} MB GPU
</span>
)}
{metrics.topCommand && (
<span className="truncate">
{metrics.topCommand} ({metrics.processCount})
</span>
)}
</>
)}
<div className="ml-auto flex items-center gap-2">
<button
className="text-muted-foreground hover:text-foreground"
title={t("tmuxMonitor.splitRight")}
aria-label={t("tmuxMonitor.splitRight")}
onClick={() => onSplit("h")}
>
<SquareSplitHorizontal className="size-3.5" />
</button>
<button
className="text-muted-foreground hover:text-foreground"
title={t("tmuxMonitor.splitDown")}
aria-label={t("tmuxMonitor.splitDown")}
onClick={() => onSplit("v")}
>
<SquareSplitVertical className="size-3.5" />
</button>
<button
className="text-muted-foreground hover:text-destructive"
title={t("tmuxMonitor.killPane")}
aria-label={t("tmuxMonitor.killPane")}
onClick={onKillPane}
>
<Trash2 className="size-3.5" />
</button>
<div className="h-3.5 border-l border-border" />
<button
className="text-muted-foreground hover:text-foreground"
title={t("tmuxMonitor.reattach")}
aria-label={t("tmuxMonitor.reattach")}
onClick={reattach}
>
<RotateCw className="size-3.5" />
</button>
<button
className="text-muted-foreground hover:text-foreground"
title={t("tmuxMonitor.closePreview")}
aria-label={t("tmuxMonitor.closePreview")}
onClick={onClose}
>
<X className="size-3.5" />
</button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-hidden">
<CommandHistoryProvider key={attachNonce}>
<Terminal
ref={terminalRef}
hostConfig={
{
...host,
sshPort: host.port,
instanceId: instanceIdRef.current,
} as TerminalHostConfig
}
isVisible={true}
title={pane.sessionName}
showTitle={false}
splitScreen={false}
tmuxAttachSession={pane.sessionName}
onClose={onClose}
/>
</CommandHistoryProvider>
</div>
</>
);
}
@@ -0,0 +1,85 @@
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import type { TmuxSearchMatch, TmuxSearchResult } from "@/api/tmux-monitor-api";
interface SearchResultsProps {
results: TmuxSearchMatch[];
searching: boolean;
/** The query that produced these results, used to highlight matches. */
query: string;
/** Search-limit info; renders a "partial results" note when truncated. */
limits: Pick<
TmuxSearchResult,
"truncated" | "searchedLines" | "maxPanes"
> | null;
onSelect: (match: TmuxSearchMatch) => void;
onClose: () => void;
}
function highlightMatch(text: string, query: string): ReactNode {
if (!query) return text;
const index = text.toLowerCase().indexOf(query.toLowerCase());
if (index === -1) return text;
return (
<>
{text.slice(0, index)}
<mark className="rounded-sm bg-accent-brand/30 px-0 text-foreground">
{text.slice(index, index + query.length)}
</mark>
{text.slice(index + query.length)}
</>
);
}
export function SearchResults({
results,
searching,
query,
limits,
onSelect,
onClose,
}: SearchResultsProps) {
const { t } = useTranslation();
return (
<div className="max-h-56 overflow-y-auto border-b border-border bg-card">
<div className="flex items-center justify-between gap-2 px-3 py-1.5">
<span className="truncate text-xs text-muted-foreground">
{searching
? t("common.loading")
: t("tmuxMonitor.searchResults", { count: results.length })}
{!searching && limits?.truncated && (
<span className="ml-2 text-muted-foreground/70">
{t("tmuxMonitor.searchTruncated", {
lines: limits.searchedLines,
panes: limits.maxPanes,
})}
</span>
)}
</span>
<button
className="text-muted-foreground hover:text-foreground"
title={t("tmuxMonitor.closeSearchResults")}
onClick={onClose}
>
<X className="size-3.5" />
</button>
</div>
{results.map((match, i) => (
<div
key={`${match.paneId}-${match.line}-${i}`}
className="flex cursor-pointer items-baseline gap-2 px-3 py-1 text-xs hover:bg-muted/40"
onClick={() => onSelect(match)}
>
<span className="shrink-0 font-medium text-primary">
{match.sessionName} · {match.paneId}
</span>
<span className="truncate font-mono text-muted-foreground">
{highlightMatch(match.text, query)}
</span>
</div>
))}
</div>
);
}
@@ -0,0 +1,451 @@
// Session/window/pane tree styled after the VSCode "tmux manager" view.
// Each session is a single row: status dot, name, inline tag badges, and a
// right-aligned meta label (relative time · cpu · mem) that is overlaid by
// the hover actions (attach, new window, …-menu). The overlay uses a grid
// stack with opacity so rows never shift and the actions stay reachable via
// keyboard focus.
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
AppWindow,
BarChart2,
ChevronDown,
ChevronRight,
MoreHorizontal,
Pencil,
Play,
Plus,
SquareSplitHorizontal,
SquareSplitVertical,
SquareTerminal,
Tag,
Trash2,
X,
} from "lucide-react";
import { Badge } from "@/components/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/popover";
import type {
TmuxPaneMetrics,
TmuxSessionOverview,
} from "@/api/tmux-monitor-api";
import { formatMem, formatRelativeTime } from "./format";
import type { SelectedPane } from "./types";
export interface SessionMetricsAgg {
cpu: number;
memKb: number;
gpuMb: number;
}
const MAX_INLINE_TAGS = 2;
interface SessionTreeProps {
sessions: TmuxSessionOverview[];
expandedSessions: Set<string>;
onToggleSession: (name: string) => void;
selectedPaneId: string | null;
onSelectPane: (pane: SelectedPane) => void;
metricsByPane: Map<string, TmuxPaneMetrics>;
metricsBySession: Map<string, SessionMetricsAgg>;
/** Open the tag editor for a session (… menu). */
onEditTags: (sessionName: string) => void;
/** Open a terminal attached to the session (the ▷ hover action). */
onAttachSession: (sessionName: string) => void;
/** Create a new window in the session (the + hover action). */
onNewWindow: (sessionName: string) => void;
/** Open the rename dialog for a session (… menu). */
onRenameSession: (sessionName: string) => void;
/** Open the kill confirmation for a session (… menu). */
onKillSession: (sessionName: string) => void;
/** Open the kill confirmation for a single pane (hover ✕ on pane rows). */
onKillPane: (paneId: string) => void;
/** Split the window containing a pane (hover actions on pane rows). */
onSplitPane: (paneId: string, direction: "h" | "v") => void;
/** Open the kill confirmation for a window (hover ✕ on window rows). */
onKillWindow: (sessionName: string, windowIndex: number) => void;
/** Narrow tree panel: the inline time/cpu/mem label is hidden (the hover
* tooltip still carries the full status). */
compact: boolean;
/** Timestamp used to render relative times; bumped periodically by the
* parent so "Xm ago" labels do not go stale. */
now: number;
}
export function SessionTree({
sessions,
expandedSessions,
onToggleSession,
selectedPaneId,
onSelectPane,
metricsByPane,
metricsBySession,
onEditTags,
onAttachSession,
onNewWindow,
onRenameSession,
onKillSession,
onKillPane,
onSplitPane,
onKillWindow,
compact,
now,
}: SessionTreeProps) {
const { t } = useTranslation();
// Collapsed windows, keyed "session windowIndex". Windows default to
// expanded (panes visible) like the VSCode tmux manager.
const [collapsedWindows, setCollapsedWindows] = useState<Set<string>>(
new Set(),
);
function windowKey(sessionName: string, windowIndex: number): string {
return `${sessionName} ${windowIndex}`;
}
function toggleWindow(sessionName: string, windowIndex: number) {
setCollapsedWindows((prev) => {
const next = new Set(prev);
const key = windowKey(sessionName, windowIndex);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}
function metaLabel(session: TmuxSessionOverview): string {
const time = formatRelativeTime(session.lastActivity, now, t);
const agg = metricsBySession.get(session.name);
if (!agg) return time;
return `${time} · ${agg.cpu.toFixed(0)}% · ${formatMem(agg.memKb)}`;
}
return (
<>
{sessions.map((session) => {
const expanded = expandedSessions.has(session.name);
const agg = metricsBySession.get(session.name);
const paneCount = session.windows.reduce(
(sum, w) => sum + w.panes.length,
0,
);
return (
<div key={session.name} className="mb-0.5">
<div
className="group flex cursor-pointer items-center gap-1.5 rounded-md px-2 py-1 hover:bg-muted/40"
onClick={() => onToggleSession(session.name)}
>
{expanded ? (
<ChevronDown className="size-3.5 shrink-0" />
) : (
<ChevronRight className="size-3.5 shrink-0" />
)}
<span
className={`size-2 shrink-0 rounded-full ${session.attachedClients > 0 ? "bg-accent-brand" : "bg-muted-foreground/40"}`}
title={
session.attachedClients > 0
? t("tmuxMonitor.attached")
: t("tmuxMonitor.detached")
}
/>
<span className="truncate text-sm font-medium">
{session.name}
</span>
{session.tags.slice(0, MAX_INLINE_TAGS).map((tag) => (
<Badge
key={tag}
variant="secondary"
className="h-4 shrink-0 px-1.5 text-[10px]"
>
{tag}
</Badge>
))}
{session.tags.length > MAX_INLINE_TAGS && (
<Badge
variant="secondary"
className="h-4 shrink-0 px-1.5 text-[10px]"
title={session.tags.join(", ")}
>
+{session.tags.length - MAX_INLINE_TAGS}
</Badge>
)}
{/* Meta label and hover actions stacked in one grid cell */}
<span className="ml-auto grid shrink-0">
{!compact && (
<span className="pointer-events-none col-start-1 row-start-1 justify-self-end self-center text-xs text-muted-foreground transition-opacity group-hover:opacity-0 group-focus-within:opacity-0">
{metaLabel(session)}
</span>
)}
<span className="pointer-events-none z-10 col-start-1 row-start-1 flex items-center gap-1.5 justify-self-end opacity-0 transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100">
<button
className="-m-1 rounded p-1 text-muted-foreground hover:text-foreground"
title={t("tmuxMonitor.attachSessionTooltip", {
session: session.name,
})}
aria-label={t("tmuxMonitor.attachSessionTooltip", {
session: session.name,
})}
onClick={(e) => {
e.stopPropagation();
onAttachSession(session.name);
}}
>
<Play className="size-3.5" />
</button>
<button
className="-m-1 rounded p-1 text-muted-foreground hover:text-foreground"
title={t("tmuxMonitor.newWindow")}
aria-label={t("tmuxMonitor.newWindow")}
onClick={(e) => {
e.stopPropagation();
onNewWindow(session.name);
}}
>
<Plus className="size-3.5" />
</button>
<Popover>
<PopoverTrigger asChild>
<button
className="-m-1 rounded p-1 text-muted-foreground hover:text-foreground data-[state=open]:text-foreground"
title={t("tmuxMonitor.sessionStats")}
aria-label={t("tmuxMonitor.sessionStats")}
onClick={(e) => e.stopPropagation()}
>
<BarChart2 className="size-3.5" />
</button>
</PopoverTrigger>
<PopoverContent
side="right"
align="start"
sideOffset={8}
className="w-56 rounded-none border border-border p-0 shadow-md"
onClick={(e) => e.stopPropagation()}
>
<div className="w-full px-3 py-2">
<div className="mb-1 flex items-center gap-1.5">
<span
className={`size-2 rounded-full ${session.attachedClients > 0 ? "bg-accent-brand" : "bg-muted-foreground/40"}`}
/>
<span className="text-xs font-semibold">
{session.name}
</span>
<span className="ml-auto text-[10px] text-muted-foreground">
{session.attachedClients > 0
? t("tmuxMonitor.attached")
: t("tmuxMonitor.detached")}
</span>
</div>
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-[11px]">
<dt className="text-muted-foreground">
{t("tmuxMonitor.statusActivity")}
</dt>
<dd className="text-right">
{formatRelativeTime(session.lastActivity, now, t)}
</dd>
<dt className="text-muted-foreground">
{t("tmuxMonitor.statusWindows")}
</dt>
<dd className="text-right">
{session.windows.length} / {paneCount}{" "}
{t("tmuxMonitor.statusPanes")}
</dd>
<dt className="text-muted-foreground">CPU</dt>
<dd className="text-right">
{agg ? `${agg.cpu.toFixed(1)}%` : "—"}
</dd>
<dt className="text-muted-foreground">RAM</dt>
<dd className="text-right">
{agg ? formatMem(agg.memKb) : "—"}
</dd>
{agg && agg.gpuMb > 0 && (
<>
<dt className="text-muted-foreground">GPU</dt>
<dd className="text-right">{agg.gpuMb} MB</dd>
</>
)}
{session.tags.length > 0 && (
<>
<dt className="text-muted-foreground">
{t("tmuxMonitor.statusTags")}
</dt>
<dd className="truncate text-right">
{session.tags.join(", ")}
</dd>
</>
)}
</dl>
</div>
</PopoverContent>
</Popover>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="-m-1 rounded p-1 text-muted-foreground hover:text-foreground data-[state=open]:text-foreground"
title={t("tmuxMonitor.moreActions")}
aria-label={t("tmuxMonitor.moreActions")}
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="min-w-36"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenuItem
className="whitespace-nowrap"
onSelect={() =>
setTimeout(() => onRenameSession(session.name), 0)
}
>
<Pencil className="size-3.5" />
{t("tmuxMonitor.rename")}
</DropdownMenuItem>
<DropdownMenuItem
className="whitespace-nowrap"
onSelect={() =>
setTimeout(() => onEditTags(session.name), 0)
}
>
<Tag className="size-3.5" />
{t("tmuxMonitor.editTags")}
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
className="whitespace-nowrap"
onSelect={() =>
setTimeout(() => onKillSession(session.name), 0)
}
>
<Trash2 className="size-3.5" />
{t("tmuxMonitor.kill")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</span>
</span>
</div>
{expanded &&
session.windows.map((win) => {
const winCollapsed = collapsedWindows.has(
windowKey(session.name, win.index),
);
return (
<div key={win.index} className="ml-4">
<div
className="group flex cursor-pointer items-center gap-1 rounded-md px-2 py-0.5 text-xs text-muted-foreground hover:bg-muted/40"
onClick={() => toggleWindow(session.name, win.index)}
>
{winCollapsed ? (
<ChevronRight className="size-3 shrink-0" />
) : (
<ChevronDown className="size-3 shrink-0" />
)}
<AppWindow className="size-3 shrink-0" />
<span className="truncate">
{win.index}: {win.name}
</span>
<button
className="-m-1 ml-auto shrink-0 rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100"
title={t("tmuxMonitor.killWindow")}
aria-label={t("tmuxMonitor.killWindow")}
onClick={(e) => {
e.stopPropagation();
onKillWindow(session.name, win.index);
}}
>
<X className="size-3" />
</button>
</div>
{!winCollapsed &&
win.panes.map((pane) => {
const m = metricsByPane.get(pane.id);
const isSelected = selectedPaneId === pane.id;
return (
<div
key={pane.id}
className={`group ml-5 flex cursor-pointer items-center gap-1.5 rounded-md px-2 py-1 text-xs hover:bg-muted/40 ${isSelected ? "bg-accent-brand/10" : ""}`}
onClick={() =>
onSelectPane({
paneId: pane.id,
sessionName: session.name,
windowIndex: win.index,
})
}
>
<SquareTerminal className="size-3 shrink-0" />
<span className="truncate">
{pane.index}: {pane.command}
</span>
<span
className="min-w-0 flex-1 truncate text-muted-foreground/70"
title={pane.path}
>
{pane.path}
</span>
{/* Same overlay stack as the session rows: cpu
label and hover actions share one grid cell so the
buttons never widen the row (they would get
clipped on a narrow panel). */}
<span className="ml-auto grid shrink-0">
{m && m.cpuPercent > 0 && (
<span className="pointer-events-none col-start-1 row-start-1 justify-self-end self-center text-[10px] text-muted-foreground transition-opacity group-hover:opacity-0 group-focus-within:opacity-0">
{m.cpuPercent.toFixed(0)}%
</span>
)}
<span className="pointer-events-none z-10 col-start-1 row-start-1 flex items-center gap-1 justify-self-end opacity-0 transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100">
<button
className="-m-1 rounded p-1 text-muted-foreground hover:text-foreground"
title={t("tmuxMonitor.splitRight")}
aria-label={t("tmuxMonitor.splitRight")}
onClick={(e) => {
e.stopPropagation();
onSplitPane(pane.id, "h");
}}
>
<SquareSplitHorizontal className="size-3" />
</button>
<button
className="-m-1 rounded p-1 text-muted-foreground hover:text-foreground"
title={t("tmuxMonitor.splitDown")}
aria-label={t("tmuxMonitor.splitDown")}
onClick={(e) => {
e.stopPropagation();
onSplitPane(pane.id, "v");
}}
>
<SquareSplitVertical className="size-3" />
</button>
<button
className="-m-1 rounded p-1 text-muted-foreground hover:text-destructive"
title={t("tmuxMonitor.killPane")}
aria-label={t("tmuxMonitor.killPane")}
onClick={(e) => {
e.stopPropagation();
onKillPane(pane.id);
}}
>
<X className="size-3" />
</button>
</span>
</span>
</div>
);
})}
</div>
);
})}
</div>
);
})}
</>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,19 @@
import React from "react";
import { TmuxMonitor } from "@/features/tmux-monitor/TmuxMonitor.tsx";
interface TmuxMonitorAppProps {
hostId?: string;
}
const TmuxMonitorApp: React.FC<TmuxMonitorAppProps> = ({ hostId }) => {
const parsed = hostId ? parseInt(hostId, 10) : NaN;
return (
<div className="h-screen w-screen overflow-hidden">
<TmuxMonitor
initialHostId={Number.isFinite(parsed) ? parsed : undefined}
/>
</div>
);
};
export default TmuxMonitorApp;
+20
View File
@@ -0,0 +1,20 @@
export function formatRelativeTime(
unixSeconds: number,
nowMs: number,
t: (k: string, o?: object) => string,
): string {
if (!unixSeconds) return "";
const diff = Math.max(0, nowMs / 1000 - unixSeconds);
if (diff < 60) return t("tmuxMonitor.timeJustNow");
if (diff < 3600)
return t("tmuxMonitor.timeMinutes", { count: Math.floor(diff / 60) });
if (diff < 86400)
return t("tmuxMonitor.timeHours", { count: Math.floor(diff / 3600) });
return t("tmuxMonitor.timeDays", { count: Math.floor(diff / 86400) });
}
export function formatMem(kb: number): string {
if (kb >= 1024 * 1024) return `${(kb / 1024 / 1024).toFixed(1)} GB`;
if (kb >= 1024) return `${(kb / 1024).toFixed(0)} MB`;
return `${kb} KB`;
}
+5
View File
@@ -0,0 +1,5 @@
export interface SelectedPane {
paneId: string;
sessionName: string;
windowIndex: number;
}
+10
View File
@@ -5,6 +5,7 @@ import { Card } from "@/components/card";
import {
AlertCircle,
Clock,
ExternalLink,
Network,
Play,
RefreshCw,
@@ -426,6 +427,15 @@ export function TunnelTab({ host }: { label: string; host?: DemoHost }) {
</div>
</div>
</div>
<a
href="https://docs.termix.site/features/networking/tunnels"
target="_blank"
rel="noreferrer"
className="flex items-center justify-center size-9 text-muted-foreground hover:text-foreground transition-colors"
title={t("hosts.docsLink")}
>
<ExternalLink className="size-4" />
</a>
</Card>
{tunnels.length > 0 ? (
+86
View File
@@ -0,0 +1,86 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { copyToClipboard } from "./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);
});
});
+48
View File
@@ -0,0 +1,48 @@
/**
* Copies text to the clipboard, working around environments where the async
* Clipboard API is unavailable - notably Brave and any non-HTTPS origin, where
* `navigator.clipboard` is undefined. Tries the Electron bridge first, then the
* async Clipboard API, then a hidden-textarea execCommand fallback.
*
* Returns true on success so callers can decide whether to show a success or
* error toast.
*/
export async function copyToClipboard(text: string): Promise<boolean> {
if (window.electronClipboard) {
try {
await window.electronClipboard.writeText(text);
return true;
} catch {
// fall through to browser approaches
}
}
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// fall through to the legacy fallback
}
}
return legacyCopy(text);
}
function legacyCopy(text: string): boolean {
try {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.top = "-9999px";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.select();
const ok = document.execCommand("copy");
document.body.removeChild(textarea);
return ok;
} catch {
return false;
}
}
+168 -13
View File
@@ -9,17 +9,36 @@ describe("highlightTerminalOutput", () => {
expect(highlightTerminalOutput(" ")).toBe(" ");
});
it("wraps error keywords in red ANSI codes", () => {
it("wraps ERROR in bright red", () => {
const out = highlightTerminalOutput("Something ERROR happened");
expect(out).toContain(ESC + "[91m");
expect(out).toContain(ESC + "[0m");
expect(out).toContain("ERROR");
});
it("highlights IPv4 addresses", () => {
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"); // magenta
expect(out).toContain(ESC + "[35m");
});
it("leaves text with no matchable tokens unchanged", () => {
@@ -27,20 +46,156 @@ describe("highlightTerminalOutput", () => {
expect(highlightTerminalOutput(plain)).toBe(plain);
});
it("does not re-highlight text that already contains many ANSI codes", () => {
let heavy = "";
for (let i = 0; i < 12; i++) heavy += `${ESC}[32mgreen${ESC}[0m `;
expect(highlightTerminalOutput(heavy)).toBe(heavy);
});
it("leaves text ending in an incomplete ANSI escape untouched", () => {
const partial = `loading${ESC}[`;
expect(highlightTerminalOutput(partial)).toBe(partial);
});
it("does not exceed MAX_LINE_LENGTH processing on huge lines", () => {
const huge = "ERROR " + "x".repeat(6000);
// Over the 5000 char cap, highlightPlainText returns the input unchanged.
it("skips TUI cursor-positioning frames (nano/vim protection)", () => {
const tuiFrame = `${ESC}[H${ESC}[2J some nano content`;
expect(highlightTerminalOutput(tuiFrame)).toBe(tuiFrame);
});
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";
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");
});
});
+280 -198
View File
@@ -1,36 +1,36 @@
const ANSI_CODES = {
const ANSI = {
reset: "\x1b[0m",
colors: {
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
magenta: "\x1b[35m",
cyan: "\x1b[36m",
white: "\x1b[37m",
brightBlack: "\x1b[90m",
brightRed: "\x1b[91m",
brightGreen: "\x1b[92m",
brightYellow: "\x1b[93m",
brightBlue: "\x1b[94m",
brightMagenta: "\x1b[95m",
brightCyan: "\x1b[96m",
brightWhite: "\x1b[97m",
},
styles: {
bold: "\x1b[1m",
dim: "\x1b[2m",
italic: "\x1b[3m",
underline: "\x1b[4m",
},
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
magenta: "\x1b[35m",
cyan: "\x1b[36m",
brightBlack: "\x1b[90m",
brightRed: "\x1b[91m",
brightGreen: "\x1b[92m",
brightYellow: "\x1b[93m",
brightBlue: "\x1b[94m",
brightCyan: "\x1b[96m",
bold: "\x1b[1m",
underline: "\x1b[4m",
} as const;
export interface SyntaxHighlightOptions {
logLevels?: boolean;
paths?: boolean;
timestamps?: boolean;
ipAddresses?: boolean;
urls?: boolean;
numbers?: boolean;
}
interface HighlightPattern {
name: string;
regex: RegExp;
ansiCode: string;
priority: number;
quickCheck?: string;
category: keyof SyntaxHighlightOptions;
}
interface MatchResult {
@@ -38,226 +38,308 @@ interface MatchResult {
end: number;
ansiCode: string;
priority: number;
}
const MAX_LINE_LENGTH = 5000;
const MAX_ANSI_CODES = 10;
const PATTERNS: HighlightPattern[] = [
{
name: "ipv4",
regex:
/(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?::\d{1,5})?/g,
ansiCode: ANSI_CODES.colors.magenta,
priority: 10,
},
{
name: "log-error",
regex:
/\b(ERROR|FATAL|CRITICAL|FAIL(?:ED)?|denied|invalid|DENIED)\b|\[ERROR\]/gi,
ansiCode: ANSI_CODES.colors.brightRed,
priority: 9,
},
{
name: "log-warn",
regex: /\b(WARN(?:ING)?|ALERT)\b|\[WARN(?:ING)?\]/gi,
ansiCode: ANSI_CODES.colors.yellow,
priority: 9,
},
{
name: "log-success",
regex:
/\b(SUCCESS|OK|PASS(?:ED)?|COMPLETE(?:D)?|connected|active|up|Up|UP|FULL)\b/gi,
ansiCode: ANSI_CODES.colors.brightGreen,
priority: 8,
},
{
name: "url",
regex: /https?:\/\/[^\s\])}]+/g,
ansiCode: `${ANSI_CODES.colors.blue}${ANSI_CODES.styles.underline}`,
priority: 8,
},
{
name: "path-absolute",
regex: /\/[a-zA-Z][a-zA-Z0-9_\-@.]*(?:\/[a-zA-Z0-9_\-@.]+)+/g,
ansiCode: ANSI_CODES.colors.cyan,
priority: 7,
},
{
name: "path-home",
regex: /~\/[a-zA-Z0-9_\-@./]+/g,
ansiCode: ANSI_CODES.colors.cyan,
priority: 7,
},
{
name: "log-info",
regex: /\bINFO\b|\[INFO\]/gi,
ansiCode: ANSI_CODES.colors.blue,
priority: 6,
},
{
name: "log-debug",
regex: /\b(?:DEBUG|TRACE)\b|\[(?:DEBUG|TRACE)\]/gi,
ansiCode: ANSI_CODES.colors.brightBlack,
priority: 6,
},
];
function hasExistingAnsiCodes(text: string): boolean {
const ansiCount = (
text.match(
/\x1b[[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-PRZcf-nq-uy=><~]/g,
) || []
).length;
return ansiCount > MAX_ANSI_CODES;
}
function hasIncompleteAnsiSequence(text: string): boolean {
return /\x1b(?:\[(?:[0-9;?>=!]*)?)?$/.test(text);
// capture group offset: some patterns capture a sub-group rather than whole match
captureStart?: number;
captureEnd?: number;
}
interface TextSegment {
isAnsi: boolean;
content: string;
// the last active SGR code before this segment (for state restoration)
activeSgr?: string;
}
const MAX_LINE_LENGTH = 2000;
// Cursor-positioning and erase sequences used by TUI apps (nano, vim, htop).
// If a chunk contains these, we skip highlighting entirely.
const TUI_SEQUENCE = /\x1b\[[\d;]*[ABCDEFGHJKST]/;
// Matches any complete ANSI escape sequence
const ANSI_REGEX = /\x1b(?:[@-Z\\-_]|\[[0-9;?>=!]*[@-~])/g;
// Matches SGR sequences (color/style setters) specifically — used to track active color state
const SGR_REGEX = /\x1b\[[0-9;]*m/;
// All patterns, ordered roughly by specificity. Priority determines which wins on overlap.
const ALL_PATTERNS: HighlightPattern[] = [
// IPv4 with optional :port — well-bounded, very specific
{
name: "ipv4",
regex:
/(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?::\d{1,5})?/g,
ansiCode: ANSI.magenta,
priority: 10,
category: "ipAddresses",
},
// Bracket timestamps [HH:MM] or [HH:MM:SS] — tight range checks prevent false positives
{
name: "timestamp-bracket",
regex: /\[(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?\]/g,
ansiCode: ANSI.brightBlack,
priority: 9,
category: "timestamps",
},
// ISO 8601 date with optional time. Require non-digit before to avoid matching
// inside version strings like "1.2024.3". The \b on the right ensures we don't
// match partial tokens.
{
name: "timestamp-iso",
regex:
/(?<![0-9])\b\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])(?:[T ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?\b/g,
ansiCode: ANSI.brightBlack,
priority: 9,
category: "timestamps",
},
// Error-level keywords — case-insensitive, catches "Error", "error", "FAILED", etc.
{
name: "log-error",
regex:
/\b(?:error|fatal|critical|fail(?:ed)?|denied|exception)\b|\[(?:error|fatal|critical)\]/gi,
ansiCode: ANSI.brightRed,
priority: 9,
category: "logLevels",
},
// Warning-level keywords — case-insensitive
{
name: "log-warn",
regex: /\b(?:warn(?:ing)?|alert|caution)\b|\[warn(?:ing)?\]/gi,
ansiCode: ANSI.brightYellow,
priority: 9,
category: "logLevels",
},
// Success keywords — kept conservative to avoid noise
{
name: "log-success",
regex: /\b(?:success(?:ful(?:ly)?)?|pass(?:ed)?|complete(?:d)?|ok\b)\b/gi,
ansiCode: ANSI.brightGreen,
priority: 8,
category: "logLevels",
},
// URLs — blue + underline
{
name: "url",
regex: /https?:\/\/[^\s\])}>"']+/g,
ansiCode: `${ANSI.blue}${ANSI.underline}`,
priority: 8,
category: "urls",
},
// Absolute paths — permissive character class that allows *, ?, [], globs, etc.
// Requires at least one slash after the root segment so we don't match bare /dev or /tmp.
// Boundary: must start at beginning of text or after whitespace/colon/comma/paren.
{
name: "path-absolute",
regex:
/(?:(?<=^)|(?<=[\s:,;('"]))\/[^\s"'`|<>&;\\]+(?:\/[^\s"'`|<>&;\\]+)+/g,
ansiCode: ANSI.cyan,
priority: 7,
category: "paths",
},
// Home-relative paths
{
name: "path-home",
regex: /~\/[^\s"'`|<>&;(){}[\]\\]+/g,
ansiCode: ANSI.cyan,
priority: 7,
category: "paths",
},
// Info keyword
{
name: "log-info",
regex: /\binfo\b|\[info\]/gi,
ansiCode: ANSI.blue,
priority: 6,
category: "logLevels",
},
// Debug/trace
{
name: "log-debug",
regex: /\b(?:debug|trace|verbose)\b|\[(?:debug|trace)\]/gi,
ansiCode: ANSI.brightBlack,
priority: 6,
category: "logLevels",
},
// Labeled numbers: port 8080, exit 1, code 127, status 404, signal 9, returned 0
// Captures just the number portion so only the digit is highlighted.
{
name: "number-labeled",
regex: /\b(?:port|exit|code|status|signal|returned?)\s+(\d+)\b/gi,
ansiCode: ANSI.brightCyan,
priority: 5,
category: "numbers",
},
];
function hasIncompleteAnsiSequence(text: string): boolean {
return /\x1b\[[0-9;?>=!]*$/.test(text);
}
function parseAnsiSegments(text: string): TextSegment[] {
const segments: TextSegment[] = [];
const ansiRegex = /\x1b(?:[@-Z\\-_]|\[[0-9;?>=!]*[@-~])/g;
ANSI_REGEX.lastIndex = 0;
let lastIndex = 0;
let activeSgr = "";
let match;
while ((match = ansiRegex.exec(text)) !== null) {
while ((match = ANSI_REGEX.exec(text)) !== null) {
if (match.index > lastIndex) {
segments.push({
isAnsi: false,
content: text.slice(lastIndex, match.index),
activeSgr,
});
}
segments.push({
isAnsi: true,
content: match[0],
});
lastIndex = ansiRegex.lastIndex;
const seq = match[0];
segments.push({ isAnsi: true, content: seq });
// Track the active SGR state so we can restore it after a highlight reset
if (SGR_REGEX.test(seq)) {
activeSgr = seq === ANSI.reset ? "" : seq;
}
lastIndex = ANSI_REGEX.lastIndex;
}
if (lastIndex < text.length) {
segments.push({
isAnsi: false,
content: text.slice(lastIndex),
});
segments.push({ isAnsi: false, content: text.slice(lastIndex), activeSgr });
}
return segments;
}
function highlightPlainText(text: string): string {
if (text.length > MAX_LINE_LENGTH) {
return text;
}
if (!text.trim()) {
return text;
}
function highlightPlainText(
text: string,
activePatterns: HighlightPattern[],
activeSgr: string,
): string {
if (text.length > MAX_LINE_LENGTH || !text.trim()) return text;
const matches: MatchResult[] = [];
for (const pattern of PATTERNS) {
for (const pattern of activePatterns) {
pattern.regex.lastIndex = 0;
let match;
while ((match = pattern.regex.exec(text)) !== null) {
matches.push({
start: match.index,
end: match.index + match[0].length,
ansiCode: pattern.ansiCode,
priority: pattern.priority,
});
let m;
while ((m = pattern.regex.exec(text)) !== null) {
// For patterns with a capture group (labeled numbers), highlight only the capture
if (m[1] !== undefined) {
const captureOffset = m[0].indexOf(m[1], m[0].search(/\d/));
const captureStart = m.index + captureOffset;
const captureEnd = captureStart + m[1].length;
matches.push({
start: m.index,
end: m.index + m[0].length,
ansiCode: pattern.ansiCode,
priority: pattern.priority,
captureStart,
captureEnd,
});
} else {
matches.push({
start: m.index,
end: m.index + m[0].length,
ansiCode: pattern.ansiCode,
priority: pattern.priority,
});
}
}
}
if (matches.length === 0) {
return text;
}
if (matches.length === 0) return text;
matches.sort((a, b) => {
if (a.priority !== b.priority) {
return b.priority - a.priority;
}
return a.start - b.start;
});
// Highest priority wins; ties broken by earlier start position
matches.sort((a, b) =>
a.priority !== b.priority ? b.priority - a.priority : a.start - b.start,
);
const appliedRanges: Array<{ start: number; end: number }> = [];
const finalMatches = matches.filter((match) => {
const overlaps = appliedRanges.some(
(range) =>
(match.start >= range.start && match.start < range.end) ||
(match.end > range.start && match.end <= range.end) ||
(match.start <= range.start && match.end >= range.end),
const used: Array<{ start: number; end: number }> = [];
const final = matches.filter((m) => {
const overlaps = used.some(
(r) =>
(m.start >= r.start && m.start < r.end) ||
(m.end > r.start && m.end <= r.end) ||
(m.start <= r.start && m.end >= r.end),
);
if (!overlaps) {
appliedRanges.push({ start: match.start, end: match.end });
used.push({ start: m.start, end: m.end });
return true;
}
return false;
});
let result = text;
finalMatches.reverse().forEach((match) => {
const before = result.slice(0, match.start);
const matched = result.slice(match.start, match.end);
const after = result.slice(match.end);
// Apply in reverse order so indices stay valid as we insert escape codes
final.sort((a, b) => a.start - b.start).reverse();
result = before + match.ansiCode + matched + ANSI_CODES.reset + after;
});
let result = text;
for (const m of final) {
const hs = m.captureStart ?? m.start;
const he = m.captureEnd ?? m.end;
// After reset, re-emit the SGR that was active before this plain-text segment
// so we don't clobber colors set by the server earlier in the line.
const restore = activeSgr || "";
result =
result.slice(0, hs) +
m.ansiCode +
result.slice(hs, he) +
ANSI.reset +
restore +
result.slice(he);
}
return result;
}
export function highlightTerminalOutput(text: string): string {
if (!text || !text.trim()) {
return text;
}
if (hasIncompleteAnsiSequence(text)) {
return text;
}
if (hasExistingAnsiCodes(text)) {
return text;
}
const segments = parseAnsiSegments(text);
if (segments.length === 0) {
return highlightPlainText(text);
}
const highlightedSegments = segments.map((segment) => {
if (segment.isAnsi) {
return segment.content;
} else {
return highlightPlainText(segment.content);
}
});
return highlightedSegments.join("");
function buildActivePatterns(
options: SyntaxHighlightOptions,
): HighlightPattern[] {
return ALL_PATTERNS.filter((p) => options[p.category] !== false);
}
export function isSyntaxHighlightingEnabled(): boolean {
try {
return localStorage.getItem("terminalSyntaxHighlighting") === "true";
} catch {
return false;
}
function highlightLine(
line: string,
activePatterns: HighlightPattern[],
): string {
const cr = line.endsWith("\r");
const bare = cr ? line.slice(0, -1) : line;
if (!bare.trim()) return line;
const segments = parseAnsiSegments(bare);
const result = segments
.map((s) =>
s.isAnsi
? s.content
: highlightPlainText(s.content, activePatterns, s.activeSgr ?? ""),
)
.join("");
return cr ? result + "\r" : result;
}
export function highlightTerminalOutput(
text: string,
options: SyntaxHighlightOptions = {},
): string {
if (!text || !text.trim()) return text;
if (hasIncompleteAnsiSequence(text)) return text;
if (TUI_SEQUENCE.test(text)) return text;
const activePatterns = buildActivePatterns(options);
if (activePatterns.length === 0) return text;
return text
.split("\n")
.map((line) => highlightLine(line, activePatterns))
.join("\n");
}

Some files were not shown because too many files have changed in this diff Show More