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
co-authored by Claude Fable 5 i/o agent LukeGus XtraLarge verybadsoldier Félix MARQUET Rabindra Kumar Meher dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Jose Tenggren copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Kang
parent 580c284065
commit ac24fdcba8
297 changed files with 111507 additions and 74240 deletions
+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 ? (