mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
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 commit4d04559ca5. * 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 commit4d04559ca5. * 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 commit4d04559ca5. * 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 commit4d04559ca5. * 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 commit4d04559ca5. * 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 commit4d04559ca5. * 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 commit4d04559ca5. * 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 commit4d04559ca5. * 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 commit4d04559ca5. * 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 commit4d04559ca5. * 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 commit4d04559ca5. * 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:
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
@@ -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"
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+9
-9
@@ -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;
|
||||
+288
-340
@@ -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)} {load[1].toFixed(2)}
|
||||
{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";
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface SelectedPane {
|
||||
paneId: string;
|
||||
sessionName: string;
|
||||
windowIndex: number;
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user