mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-16 05:13:36 +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:
+177
-18
@@ -18,6 +18,7 @@ import { QuickConnectPanel } from "@/sidebar/QuickConnectPanel";
|
||||
import { SshToolsPanel } from "@/sidebar/SshToolsPanel";
|
||||
import { SnippetsPanel } from "@/sidebar/SnippetsPanel";
|
||||
import { HistoryPanel } from "@/sidebar/HistoryPanel";
|
||||
import { SessionLogsPanel } from "@/sidebar/SessionLogsPanel";
|
||||
import { SplitScreenPanel } from "@/sidebar/SplitScreenPanel";
|
||||
import { UserProfilePanel } from "@/sidebar/UserProfilePanel";
|
||||
import { AdminSettingsPanel } from "@/sidebar/AdminSettingsPanel";
|
||||
@@ -38,6 +39,7 @@ import { applyAccentColor, applyFontSize, PANE_COUNTS } from "@/lib/theme";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import {
|
||||
getSSHHosts,
|
||||
getSSHFolders,
|
||||
getUserInfo,
|
||||
getOpenTabs,
|
||||
addOpenTab,
|
||||
@@ -80,6 +82,9 @@ function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||
enableTunnel: h.enableTunnel ?? false,
|
||||
enableFileManager: h.enableFileManager ?? false,
|
||||
enableDocker: h.enableDocker ?? false,
|
||||
enableProxmox: h.enableProxmox ?? false,
|
||||
enableTmuxMonitor: h.enableTmuxMonitor ?? false, // --- tmux-monitor ---
|
||||
proxmoxConfig: (h.proxmoxConfig as Host["proxmoxConfig"]) ?? null,
|
||||
enableRdp: h.enableRdp ?? h.connectionType === "rdp",
|
||||
enableVnc: h.enableVnc ?? h.connectionType === "vnc",
|
||||
enableTelnet: h.enableTelnet ?? h.connectionType === "telnet",
|
||||
@@ -106,7 +111,10 @@ function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||
};
|
||||
}
|
||||
|
||||
function buildHostTree(hosts: SSHHostWithStatus[]): HostFolder {
|
||||
function buildHostTree(
|
||||
hosts: SSHHostWithStatus[],
|
||||
folderMeta?: Map<string, { color?: string; icon?: string }>,
|
||||
): HostFolder {
|
||||
const root: HostFolder = { name: "root", children: [] };
|
||||
const folderMap = new Map<string, HostFolder>();
|
||||
const getOrCreateFolder = (path: string): HostFolder => {
|
||||
@@ -117,7 +125,14 @@ function buildHostTree(hosts: SSHHostWithStatus[]): HostFolder {
|
||||
for (const part of parts) {
|
||||
accumulated = accumulated ? `${accumulated} / ${part}` : part;
|
||||
if (!folderMap.has(accumulated)) {
|
||||
const folder: HostFolder = { name: part, children: [] };
|
||||
const meta = folderMeta?.get(accumulated);
|
||||
const folder: HostFolder = {
|
||||
name: part,
|
||||
path: accumulated,
|
||||
color: meta?.color,
|
||||
icon: meta?.icon,
|
||||
children: [],
|
||||
};
|
||||
folderMap.set(accumulated, folder);
|
||||
current.children.push(folder);
|
||||
}
|
||||
@@ -125,6 +140,10 @@ function buildHostTree(hosts: SSHHostWithStatus[]): HostFolder {
|
||||
}
|
||||
return current;
|
||||
};
|
||||
// Surface empty folders (created but with no hosts yet) so they stay visible.
|
||||
if (folderMeta) {
|
||||
for (const path of folderMeta.keys()) getOrCreateFolder(path);
|
||||
}
|
||||
for (const h of hosts) {
|
||||
const host = sshHostToHost(h);
|
||||
if (h.folder) {
|
||||
@@ -188,7 +207,7 @@ export function AppShell({
|
||||
const [profileDropdownOpen, setProfileDropdownOpen] = useState(false);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||
const saved = localStorage.getItem("termix_sidebarWidth");
|
||||
return saved ? parseInt(saved, 10) : 266;
|
||||
return saved ? parseInt(saved, 10) : 291;
|
||||
});
|
||||
const [sidebarDragging, setSidebarDragging] = useState(false);
|
||||
const [sidebarEditing, setSidebarEditing] = useState(false);
|
||||
@@ -274,6 +293,7 @@ export function AppShell({
|
||||
"ssh-tools": "SSH Tools",
|
||||
snippets: "Snippets",
|
||||
history: "History",
|
||||
"session-logs": t("nav.sessionLogs"),
|
||||
"split-screen": "Split Screen",
|
||||
connections: t("nav.connections"),
|
||||
"user-profile": "User Profile",
|
||||
@@ -371,15 +391,110 @@ export function AppShell({
|
||||
getUserPreferences()
|
||||
.then((prefs) => {
|
||||
setUserPrefs(prefs);
|
||||
if (prefs.theme) setTheme(prefs.theme as ThemeId);
|
||||
if (prefs.fontSize) applyFontSize(prefs.fontSize as FontSizeId);
|
||||
if (prefs.accentColor) {
|
||||
localStorage.setItem("termix-accent", prefs.accentColor);
|
||||
applyAccentColor(prefs.accentColor);
|
||||
}
|
||||
if (prefs.language && prefs.language !== i18n.language) {
|
||||
localStorage.setItem("i18nextLng", prefs.language);
|
||||
void i18n.changeLanguage(prefs.language);
|
||||
if (prefs.storageMode === "cloud") {
|
||||
// Persist the current browser values before overwriting, so any tab can restore them
|
||||
if (!localStorage.getItem("termix-local-snapshot")) {
|
||||
const SNAPSHOT_KEYS = [
|
||||
"termix-accent",
|
||||
"termix-font-size",
|
||||
"i18nextLng",
|
||||
"commandAutocomplete",
|
||||
"commandPaletteShortcutEnabled",
|
||||
"showHostTags",
|
||||
"hostTrayOnClick",
|
||||
"pinAppRail",
|
||||
"defaultSnippetFoldersCollapsed",
|
||||
"confirmSnippetExecution",
|
||||
"disableUpdateCheck",
|
||||
"confirmTabClose",
|
||||
"hiddenRailTabs",
|
||||
];
|
||||
const snap: Record<string, string | null> = {
|
||||
__theme: localStorage.getItem("termix-theme"),
|
||||
};
|
||||
for (const key of SNAPSHOT_KEYS)
|
||||
snap[key] = localStorage.getItem(key);
|
||||
localStorage.setItem("termix-local-snapshot", JSON.stringify(snap));
|
||||
}
|
||||
if (prefs.theme) setTheme(prefs.theme as ThemeId);
|
||||
if (prefs.fontSize) applyFontSize(prefs.fontSize as FontSizeId);
|
||||
if (prefs.accentColor) {
|
||||
localStorage.setItem("termix-accent", prefs.accentColor);
|
||||
applyAccentColor(prefs.accentColor);
|
||||
}
|
||||
if (prefs.language && prefs.language !== i18n.language) {
|
||||
localStorage.setItem("i18nextLng", prefs.language);
|
||||
void i18n.changeLanguage(prefs.language);
|
||||
}
|
||||
if (
|
||||
prefs.commandAutocomplete !== null &&
|
||||
prefs.commandAutocomplete !== undefined
|
||||
)
|
||||
localStorage.setItem(
|
||||
"commandAutocomplete",
|
||||
String(prefs.commandAutocomplete),
|
||||
);
|
||||
if (
|
||||
prefs.commandPaletteEnabled !== null &&
|
||||
prefs.commandPaletteEnabled !== undefined
|
||||
)
|
||||
localStorage.setItem(
|
||||
"commandPaletteShortcutEnabled",
|
||||
String(prefs.commandPaletteEnabled),
|
||||
);
|
||||
if (prefs.showHostTags !== null && prefs.showHostTags !== undefined) {
|
||||
localStorage.setItem("showHostTags", String(prefs.showHostTags));
|
||||
window.dispatchEvent(new CustomEvent("showHostTagsChanged"));
|
||||
}
|
||||
if (
|
||||
prefs.hostTrayOnClick !== null &&
|
||||
prefs.hostTrayOnClick !== undefined
|
||||
)
|
||||
localStorage.setItem(
|
||||
"hostTrayOnClick",
|
||||
String(prefs.hostTrayOnClick),
|
||||
);
|
||||
if (prefs.pinAppRail !== null && prefs.pinAppRail !== undefined)
|
||||
localStorage.setItem("pinAppRail", String(prefs.pinAppRail));
|
||||
if (
|
||||
prefs.foldersCollapsed !== null &&
|
||||
prefs.foldersCollapsed !== undefined
|
||||
)
|
||||
localStorage.setItem(
|
||||
"defaultSnippetFoldersCollapsed",
|
||||
String(prefs.foldersCollapsed),
|
||||
);
|
||||
if (
|
||||
prefs.confirmSnippetExecution !== null &&
|
||||
prefs.confirmSnippetExecution !== undefined
|
||||
)
|
||||
localStorage.setItem(
|
||||
"confirmSnippetExecution",
|
||||
String(prefs.confirmSnippetExecution),
|
||||
);
|
||||
if (
|
||||
prefs.disableUpdateCheck !== null &&
|
||||
prefs.disableUpdateCheck !== undefined
|
||||
)
|
||||
localStorage.setItem(
|
||||
"disableUpdateCheck",
|
||||
String(prefs.disableUpdateCheck),
|
||||
);
|
||||
if (
|
||||
prefs.confirmTabClose !== null &&
|
||||
prefs.confirmTabClose !== undefined
|
||||
)
|
||||
localStorage.setItem(
|
||||
"confirmTabClose",
|
||||
String(prefs.confirmTabClose),
|
||||
);
|
||||
if (
|
||||
prefs.hiddenRailTabs !== null &&
|
||||
prefs.hiddenRailTabs !== undefined
|
||||
) {
|
||||
localStorage.setItem("hiddenRailTabs", prefs.hiddenRailTabs);
|
||||
window.dispatchEvent(new CustomEvent("hiddenRailTabsChanged"));
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
@@ -389,10 +504,20 @@ export function AppShell({
|
||||
// Load real hosts from API
|
||||
const loadHosts = useCallback(async () => {
|
||||
try {
|
||||
const raw = await getSSHHosts();
|
||||
const [raw, folders] = await Promise.all([
|
||||
getSSHHosts(),
|
||||
getSSHFolders().catch(() => []),
|
||||
]);
|
||||
const converted = raw.map(sshHostToHost);
|
||||
setAllHosts(converted);
|
||||
setRealHostTree(buildHostTree(raw));
|
||||
const folderMeta = new Map<string, { color?: string; icon?: string }>();
|
||||
for (const f of folders) {
|
||||
folderMeta.set(f.name, {
|
||||
color: f.color ?? undefined,
|
||||
icon: f.icon ?? undefined,
|
||||
});
|
||||
}
|
||||
setRealHostTree(buildHostTree(raw, folderMeta));
|
||||
} catch {
|
||||
// Keep empty state on error
|
||||
} finally {
|
||||
@@ -442,7 +567,7 @@ export function AppShell({
|
||||
"telnet",
|
||||
"files",
|
||||
"docker",
|
||||
"stats",
|
||||
"host-metrics",
|
||||
"tunnel",
|
||||
];
|
||||
|
||||
@@ -637,11 +762,22 @@ export function AppShell({
|
||||
: host.enableTelnet
|
||||
? "telnet"
|
||||
: "terminal");
|
||||
// --- tmux-monitor --- singleton tab, not a per-host tab
|
||||
if (type === "tmux_monitor") {
|
||||
openSingletonTab(type, undefined, host);
|
||||
return;
|
||||
}
|
||||
openTab(host, type);
|
||||
}
|
||||
|
||||
const openSingletonTab = useCallback(
|
||||
function openSingletonTab(type: TabType, pendingEvent?: string) {
|
||||
// --- tmux-monitor --- (added optional `host` so tmux_monitor can open
|
||||
// with a preselected host; existing callers are unaffected)
|
||||
function openSingletonTab(
|
||||
type: TabType,
|
||||
pendingEvent?: string,
|
||||
host?: Host,
|
||||
) {
|
||||
if (type === "host-manager") {
|
||||
if (pendingEvent === "host-manager:add-credential") {
|
||||
setSidebarOpen(true);
|
||||
@@ -677,9 +813,15 @@ export function AppShell({
|
||||
docker: t("nav.docker"),
|
||||
tunnel: t("nav.tunnels"),
|
||||
network_graph: t("nav.networkGraph"),
|
||||
tmux_monitor: t("nav.tmuxMonitor"), // --- tmux-monitor ---
|
||||
};
|
||||
setTabs((prev) => {
|
||||
if (prev.find((t) => t.id === id)) return prev;
|
||||
const existing = prev.find((t) => t.id === id);
|
||||
if (existing) {
|
||||
// --- tmux-monitor --- refocusing with a host preselects it
|
||||
if (!host) return prev;
|
||||
return prev.map((t) => (t.id === id ? { ...t, host } : t));
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
@@ -688,6 +830,7 @@ export function AppShell({
|
||||
type,
|
||||
label: singletonLabels[type] ?? type,
|
||||
openedAt: Date.now(),
|
||||
...(host ? { host } : {}), // --- tmux-monitor ---
|
||||
},
|
||||
];
|
||||
});
|
||||
@@ -709,6 +852,9 @@ export function AppShell({
|
||||
|
||||
function doCloseTab(id: string) {
|
||||
const tabToClose = tabs.find((t) => t.id === id);
|
||||
if (tabToClose?.terminalRef?.current?.disconnect) {
|
||||
tabToClose.terminalRef.current.disconnect();
|
||||
}
|
||||
if (
|
||||
tabToClose?.instanceId &&
|
||||
PERSISTENT_TAB_TYPES.includes(tabToClose.type)
|
||||
@@ -1051,6 +1197,12 @@ export function AppShell({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{railView === "session-logs" && (
|
||||
<div className="relative flex-1 min-h-0 flex flex-col">
|
||||
<SessionLogsPanel />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{railView === "user-profile" && (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<UserProfilePanel
|
||||
@@ -1084,7 +1236,7 @@ export function AppShell({
|
||||
size="icon"
|
||||
className="h-full w-12.5 border-y-0 border-border rounded-none text-muted-foreground hover:text-foreground"
|
||||
title="Reset width"
|
||||
onClick={() => setSidebarWidth(266)}
|
||||
onClick={() => setSidebarWidth(291)}
|
||||
>
|
||||
<Maximize2 className="size-3.5" />
|
||||
</Button>
|
||||
@@ -1269,6 +1421,13 @@ export function AppShell({
|
||||
].includes(type)
|
||||
) {
|
||||
openSingletonTab(type, pendingEvent);
|
||||
} else if (type === "tmux_monitor") {
|
||||
// --- tmux-monitor --- singleton tab, optionally preselecting a host
|
||||
openSingletonTab(
|
||||
type,
|
||||
undefined,
|
||||
label ? allHosts.find((h) => h.name === label) : undefined,
|
||||
);
|
||||
} else if (label) {
|
||||
const host = allHosts.find((h) => h.name === label);
|
||||
if (host) openTab(host, type);
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
|
||||
export interface AuditLog {
|
||||
id: number;
|
||||
userId: string;
|
||||
username: string;
|
||||
action: string;
|
||||
resourceType: string;
|
||||
resourceId: string | null;
|
||||
resourceName: string | null;
|
||||
details: string | null;
|
||||
ipAddress: string | null;
|
||||
userAgent: string | null;
|
||||
success: boolean;
|
||||
errorMessage: string | null;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface AuditLogFilters {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
userId?: string;
|
||||
action?: string;
|
||||
resourceType?: string;
|
||||
success?: boolean | "";
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
export interface AuditLogResponse {
|
||||
logs: AuditLog[];
|
||||
total: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export async function getAuditLogs(
|
||||
filters: AuditLogFilters = {},
|
||||
): Promise<AuditLogResponse> {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.page) params.set("page", String(filters.page));
|
||||
if (filters.limit) params.set("limit", String(filters.limit));
|
||||
if (filters.userId) params.set("userId", filters.userId);
|
||||
if (filters.action) params.set("action", filters.action);
|
||||
if (filters.resourceType) params.set("resourceType", filters.resourceType);
|
||||
if (filters.success !== undefined && filters.success !== "")
|
||||
params.set("success", String(filters.success));
|
||||
if (filters.startDate) params.set("startDate", filters.startDate);
|
||||
if (filters.endDate) params.set("endDate", filters.endDate);
|
||||
|
||||
const response = await authApi.get(`/audit-logs?${params.toString()}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch audit logs");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAuditLogActions(): Promise<{ actions: string[] }> {
|
||||
try {
|
||||
const response = await authApi.get("/audit-logs/actions");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch audit log actions");
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@ import { authApi, handleApiError, sshHostApi } from "@/main-axios";
|
||||
import type { SSHFolder } from "@/types/index";
|
||||
import { sshLogger } from "@/lib/frontend-logger";
|
||||
|
||||
export async function getCredentials(): Promise<Record<string, unknown>> {
|
||||
export async function getCredentials(): Promise<
|
||||
Record<string, unknown>[] | Record<string, unknown>
|
||||
> {
|
||||
try {
|
||||
const response = await authApi.get("/credentials");
|
||||
return response.data;
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { dashboardApi } from "@/main-axios";
|
||||
|
||||
export interface DashboardLayout {
|
||||
cards: Array<{
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
order: number;
|
||||
panel?: "main" | "side";
|
||||
height?: number | null;
|
||||
}>;
|
||||
mainWidthPct?: number;
|
||||
}
|
||||
|
||||
export async function getDashboardPreferences(): Promise<DashboardLayout> {
|
||||
const response = await dashboardApi.get("/dashboard/preferences");
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function saveDashboardPreferences(
|
||||
layout: DashboardLayout,
|
||||
): Promise<{ success: boolean }> {
|
||||
const response = await dashboardApi.post("/dashboard/preferences", layout);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -0,0 +1,100 @@
|
||||
import { handleApiError, statsApi } from "@/main-axios";
|
||||
import type { HostMetricsLayout } from "@/types/host-metrics";
|
||||
|
||||
/**
|
||||
* Host Metrics layout persistence (server-synced per user/host) + the manager
|
||||
* card endpoints. All routes live under the `/host-metrics/*` prefix on the
|
||||
* stats app (port 30005).
|
||||
*/
|
||||
|
||||
export async function getHostMetricsLayout(
|
||||
hostId: number,
|
||||
): Promise<HostMetricsLayout | null> {
|
||||
try {
|
||||
const res = await statsApi.get(`/host-metrics/preferences/${hostId}`, {
|
||||
validateStatus: (status) => status === 200 || status === 404,
|
||||
});
|
||||
if (res.status === 404) return null;
|
||||
return res.data?.layout ?? null;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch host metrics layout");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveHostMetricsLayout(
|
||||
hostId: number,
|
||||
layout: HostMetricsLayout,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await statsApi.post(`/host-metrics/preferences/${hostId}`, layout);
|
||||
} catch (error) {
|
||||
handleApiError(error, "save host metrics layout");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Managers ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PlatformInfo {
|
||||
hasSystemd: boolean;
|
||||
pkg: "apt" | "dnf" | "yum" | "pacman" | null;
|
||||
hasCertbot: boolean;
|
||||
hasAcmeSh: boolean;
|
||||
hasDocker: boolean;
|
||||
osPrettyName: string | null;
|
||||
}
|
||||
|
||||
export async function getHostPlatform(hostId: number): Promise<PlatformInfo> {
|
||||
const res = await statsApi.get(`/host-metrics/platform/${hostId}`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/** GET a manager resource (read). */
|
||||
export async function managerGet<T>(
|
||||
hostId: number,
|
||||
resource: string,
|
||||
params?: Record<string, string | number>,
|
||||
): Promise<T> {
|
||||
const res = await statsApi.get(
|
||||
`/host-metrics/managers/${resource}/${hostId}`,
|
||||
{ params },
|
||||
);
|
||||
return res.data as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET a manager sub-resource where the host id sits in the middle of the path,
|
||||
* e.g. /managers/logs/{id}/files.
|
||||
*/
|
||||
export async function managerGetSub<T>(
|
||||
hostId: number,
|
||||
resource: string,
|
||||
sub: string,
|
||||
params?: Record<string, string | number>,
|
||||
): Promise<T> {
|
||||
const res = await statsApi.get(
|
||||
`/host-metrics/managers/${resource}/${hostId}/${sub}`,
|
||||
{ params },
|
||||
);
|
||||
return res.data as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST a manager action. `resource` is the manager name; `action` is the
|
||||
* optional sub-path (e.g. "action", "signal", "renew"). The host id always
|
||||
* sits between them: /managers/{resource}/{id}[/{action}].
|
||||
*/
|
||||
export async function managerPost<T>(
|
||||
hostId: number,
|
||||
resource: string,
|
||||
body: unknown,
|
||||
action?: string,
|
||||
): Promise<T> {
|
||||
const suffix = action ? `/${action}` : "";
|
||||
const res = await statsApi.post(
|
||||
`/host-metrics/managers/${resource}/${hostId}${suffix}`,
|
||||
body,
|
||||
);
|
||||
return res.data as T;
|
||||
}
|
||||
@@ -83,6 +83,17 @@ export interface UserPreferences {
|
||||
fontSize?: string | null;
|
||||
accentColor?: string | null;
|
||||
language?: string | null;
|
||||
storageMode?: string | null;
|
||||
commandAutocomplete?: boolean | null;
|
||||
commandPaletteEnabled?: boolean | null;
|
||||
showHostTags?: boolean | null;
|
||||
hostTrayOnClick?: boolean | null;
|
||||
pinAppRail?: boolean | null;
|
||||
foldersCollapsed?: boolean | null;
|
||||
confirmSnippetExecution?: boolean | null;
|
||||
disableUpdateCheck?: boolean | null;
|
||||
confirmTabClose?: boolean | null;
|
||||
hiddenRailTabs?: string | null;
|
||||
}
|
||||
|
||||
export async function getUserPreferences(): Promise<UserPreferences> {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
|
||||
export type SessionLogRecord = {
|
||||
id: number;
|
||||
hostId: number;
|
||||
userId: string;
|
||||
startedAt: string;
|
||||
endedAt: string | null;
|
||||
duration: number | null;
|
||||
recordingPath: string | null;
|
||||
hostName: string | null;
|
||||
hostIp: string | null;
|
||||
sizeBytes: number | null;
|
||||
};
|
||||
|
||||
export async function getSessionLogs(): Promise<SessionLogRecord[]> {
|
||||
try {
|
||||
const response = await authApi.get("/session_logs/");
|
||||
return response.data.logs;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch session logs");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSessionLogContent(id: number): Promise<string> {
|
||||
try {
|
||||
const response = await authApi.get(`/session_logs/${id}/content`, {
|
||||
responseType: "text",
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch session log content");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSessionLog(id: number): Promise<void> {
|
||||
try {
|
||||
await authApi.delete(`/session_logs/${id}`);
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "delete session log");
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,54 @@ export async function updateSessionTimeout(
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TAILSCALE SETTINGS
|
||||
// ============================================================================
|
||||
|
||||
export async function getTailscaleSettings(): Promise<{
|
||||
apiKey: string;
|
||||
hasApiKey: boolean;
|
||||
}> {
|
||||
try {
|
||||
const response = await authApi.get("/users/tailscale-settings");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch Tailscale settings");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateTailscaleSettings(
|
||||
apiKey: string,
|
||||
): Promise<{ hasApiKey: boolean }> {
|
||||
try {
|
||||
const response = await authApi.patch("/users/tailscale-settings", {
|
||||
apiKey,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "update Tailscale settings");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTailscaleDevices(): Promise<{
|
||||
devices: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
hostname: string;
|
||||
addresses: string[];
|
||||
os: string;
|
||||
lastSeen: string;
|
||||
}>;
|
||||
hasApiKey: boolean;
|
||||
}> {
|
||||
try {
|
||||
const response = await authApi.get("/tailscale/devices");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch Tailscale devices");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GUACAMOLE SETTINGS
|
||||
// ============================================================================
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { AxiosError } from "axios";
|
||||
import { getAllServerStatuses, handleApiError, sshHostApi } from "@/main-axios";
|
||||
import {
|
||||
authApi,
|
||||
getAllServerStatuses,
|
||||
handleApiError,
|
||||
sshHostApi,
|
||||
} from "@/main-axios";
|
||||
import type { SSHHost, SSHHostData, ProxyNode } from "@/types/index";
|
||||
import type { ServerStatus, SSHHostWithStatus } from "@/main-axios";
|
||||
import type { ProxmoxDiscoverResult } from "@/types/proxmox";
|
||||
|
||||
// SSH HOST MANAGEMENT
|
||||
// ============================================================================
|
||||
@@ -101,6 +107,21 @@ export async function bulkImportSSHHosts(
|
||||
}
|
||||
}
|
||||
|
||||
export async function discoverProxmoxGuests(
|
||||
hostId: number,
|
||||
): Promise<ProxmoxDiscoverResult> {
|
||||
try {
|
||||
const response = await authApi.post(
|
||||
"/proxmox/discover",
|
||||
{ hostId },
|
||||
{ timeout: 120000 },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "discover Proxmox guests");
|
||||
}
|
||||
}
|
||||
|
||||
export async function bulkUpdateSSHHosts(
|
||||
hostIds: number[],
|
||||
updates: Record<string, unknown>,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
import type { SSOProvider, SSOProviderPublic } from "@/types/index";
|
||||
|
||||
export async function getSSOProviders(): Promise<SSOProviderPublic[]> {
|
||||
try {
|
||||
const response = await authApi.get("/users/sso-providers");
|
||||
return response.data;
|
||||
} catch (error: unknown) {
|
||||
console.warn("Failed to fetch SSO providers:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAdminSSOProviders(): Promise<SSOProvider[]> {
|
||||
try {
|
||||
const response = await authApi.get("/users/sso-providers/admin");
|
||||
return Array.isArray(response.data) ? response.data : [];
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch admin SSO providers");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSSOProvider(
|
||||
data: Omit<SSOProvider, "id" | "createdAt" | "updatedAt"> & {
|
||||
config: Record<string, unknown>;
|
||||
},
|
||||
): Promise<SSOProvider> {
|
||||
try {
|
||||
const response = await authApi.post("/users/sso-providers", data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "create SSO provider");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSSOProvider(
|
||||
id: number,
|
||||
data: Partial<Omit<SSOProvider, "id" | "createdAt" | "updatedAt">> & {
|
||||
config?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<SSOProvider> {
|
||||
try {
|
||||
const response = await authApi.put(`/users/sso-providers/${id}`, data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "update SSO provider");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSSOProvider(id: number): Promise<void> {
|
||||
try {
|
||||
await authApi.delete(`/users/sso-providers/${id}`);
|
||||
} catch (error) {
|
||||
handleApiError(error, "delete SSO provider");
|
||||
}
|
||||
}
|
||||
|
||||
export async function ldapLogin(
|
||||
providerId: number,
|
||||
username: string,
|
||||
password: string,
|
||||
rememberMe: boolean,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await authApi.post("/users/ldap/login", {
|
||||
providerId,
|
||||
username,
|
||||
password,
|
||||
rememberMe,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "LDAP login");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { tmuxMonitorApi } from "@/main-axios";
|
||||
|
||||
export interface TmuxPane {
|
||||
id: string;
|
||||
index: number;
|
||||
pid: number;
|
||||
active: boolean;
|
||||
width: number;
|
||||
height: number;
|
||||
command: string;
|
||||
path: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface TmuxWindow {
|
||||
index: number;
|
||||
name: string;
|
||||
active: boolean;
|
||||
panes: TmuxPane[];
|
||||
}
|
||||
|
||||
export interface TmuxSessionOverview {
|
||||
name: string;
|
||||
created: number;
|
||||
lastActivity: number;
|
||||
attachedClients: number;
|
||||
windows: TmuxWindow[];
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface TmuxOverview {
|
||||
available: boolean;
|
||||
sessions: TmuxSessionOverview[];
|
||||
}
|
||||
|
||||
export interface TmuxSearchMatch {
|
||||
paneId: string;
|
||||
sessionName: string;
|
||||
windowIndex: number;
|
||||
line: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface TmuxPaneMetrics {
|
||||
paneId: string;
|
||||
sessionName: string;
|
||||
pid: number;
|
||||
processCount: number;
|
||||
cpuPercent: number;
|
||||
memRssKb: number;
|
||||
gpuMemMb: number;
|
||||
topCommand: string | null;
|
||||
}
|
||||
|
||||
export async function getTmuxOverview(hostId: number): Promise<TmuxOverview> {
|
||||
const response = await tmuxMonitorApi.get(`/${hostId}/overview`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** Select a pane's window+pane on the server so the attached terminal
|
||||
* (and any other attached client) switches to it. */
|
||||
export async function focusTmuxPane(
|
||||
hostId: number,
|
||||
paneId: string,
|
||||
): Promise<void> {
|
||||
await tmuxMonitorApi.post(`/${hostId}/focus`, { paneId });
|
||||
}
|
||||
|
||||
/** Create a new detached session on the host. Starts the tmux server if
|
||||
* none is running yet. */
|
||||
export async function createTmuxSession(
|
||||
hostId: number,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
await tmuxMonitorApi.post(`/${hostId}/sessions`, { name });
|
||||
}
|
||||
|
||||
/** Create a new window in an existing session. */
|
||||
export async function createTmuxWindow(
|
||||
hostId: number,
|
||||
sessionName: string,
|
||||
): Promise<void> {
|
||||
await tmuxMonitorApi.post(`/${hostId}/windows`, { sessionName });
|
||||
}
|
||||
|
||||
/** Rename a session. Saved tags follow the session to its new name. */
|
||||
export async function renameTmuxSession(
|
||||
hostId: number,
|
||||
sessionName: string,
|
||||
newName: string,
|
||||
): Promise<void> {
|
||||
await tmuxMonitorApi.post(`/${hostId}/rename`, { sessionName, newName });
|
||||
}
|
||||
|
||||
/** Kill a session (terminates all of its windows and processes). */
|
||||
export async function killTmuxSession(
|
||||
hostId: number,
|
||||
sessionName: string,
|
||||
): Promise<void> {
|
||||
await tmuxMonitorApi.post(`/${hostId}/kill`, { sessionName });
|
||||
}
|
||||
|
||||
/** Kill a window and every pane in it. */
|
||||
export async function killTmuxWindow(
|
||||
hostId: number,
|
||||
sessionName: string,
|
||||
windowIndex: number,
|
||||
): Promise<void> {
|
||||
await tmuxMonitorApi.post(`/${hostId}/kill-window`, {
|
||||
sessionName,
|
||||
windowIndex,
|
||||
});
|
||||
}
|
||||
|
||||
/** Kill a single pane (last pane of a window closes the window). */
|
||||
export async function killTmuxPane(
|
||||
hostId: number,
|
||||
paneId: string,
|
||||
): Promise<void> {
|
||||
await tmuxMonitorApi.post(`/${hostId}/kill-pane`, { paneId });
|
||||
}
|
||||
|
||||
/** Split the window containing a pane: "h" adds a pane to the right,
|
||||
* "v" below (tmux -h/-v semantics). */
|
||||
export async function splitTmuxPane(
|
||||
hostId: number,
|
||||
paneId: string,
|
||||
direction: "h" | "v",
|
||||
): Promise<void> {
|
||||
await tmuxMonitorApi.post(`/${hostId}/split`, { paneId, direction });
|
||||
}
|
||||
|
||||
export interface TmuxSearchResult {
|
||||
matches: TmuxSearchMatch[];
|
||||
/** True when a search limit was hit and the results are partial. */
|
||||
truncated: boolean;
|
||||
searchedLines: number;
|
||||
maxPanes: number;
|
||||
}
|
||||
|
||||
export async function searchTmux(
|
||||
hostId: number,
|
||||
query: string,
|
||||
): Promise<TmuxSearchResult> {
|
||||
const response = await tmuxMonitorApi.get(`/${hostId}/search`, {
|
||||
params: { q: query },
|
||||
});
|
||||
return {
|
||||
matches: response.data.matches ?? [],
|
||||
truncated: response.data.truncated ?? false,
|
||||
searchedLines: response.data.searchedLines ?? 0,
|
||||
maxPanes: response.data.maxPanes ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getTmuxMetrics(
|
||||
hostId: number,
|
||||
): Promise<TmuxPaneMetrics[]> {
|
||||
const response = await tmuxMonitorApi.get(`/${hostId}/metrics`);
|
||||
return response.data.panes ?? [];
|
||||
}
|
||||
|
||||
export async function setTmuxSessionTags(
|
||||
hostId: number,
|
||||
sessionName: string,
|
||||
tags: string[],
|
||||
): Promise<string[]> {
|
||||
const response = await tmuxMonitorApi.put(`/${hostId}/tags`, {
|
||||
sessionName,
|
||||
tags,
|
||||
});
|
||||
return response.data.tags ?? [];
|
||||
}
|
||||
@@ -231,6 +231,30 @@ export async function updatePasswordResetAllowed(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCommandHistoryEnabled(): Promise<{
|
||||
enabled: boolean;
|
||||
}> {
|
||||
try {
|
||||
const response = await authApi.get("/users/command-history-enabled");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "get command history enabled");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCommandHistoryEnabled(
|
||||
enabled: boolean,
|
||||
): Promise<{ enabled: boolean }> {
|
||||
try {
|
||||
const response = await authApi.patch("/users/command-history-enabled", {
|
||||
enabled,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "update command history enabled");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateOIDCConfig(
|
||||
config: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
|
||||
+250
-95
@@ -12,6 +12,8 @@ import {
|
||||
ArrowLeft,
|
||||
Shield,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -21,7 +23,6 @@ import {
|
||||
getRegistrationAllowed,
|
||||
getPasswordLoginAllowed,
|
||||
getPasswordResetAllowed,
|
||||
getOIDCConfig,
|
||||
getSetupRequired,
|
||||
initiatePasswordReset,
|
||||
verifyPasswordResetCode,
|
||||
@@ -34,6 +35,8 @@ import {
|
||||
getEmbeddedServerStatus,
|
||||
getCurrentToken,
|
||||
} from "@/main-axios";
|
||||
import { getSSOProviders, ldapLogin } from "@/api/sso-provider-api";
|
||||
import type { SSOProviderPublic } from "@/types/index";
|
||||
import { ElectronServerConfig as ServerConfigComponent } from "@/auth/ElectronServerConfig";
|
||||
import { ElectronLoginForm } from "@/auth/ElectronLoginForm";
|
||||
import { Checkbox } from "@/components/checkbox";
|
||||
@@ -209,7 +212,9 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
const { t } = useTranslation();
|
||||
const [view, setView] = useState<AuthView>("login");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [oidcLoading, setOidcLoading] = useState(false);
|
||||
const [providerLoading, setProviderLoading] = useState<
|
||||
Record<number, boolean>
|
||||
>({});
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
@@ -245,8 +250,11 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
const [registrationAllowed, setRegistrationAllowed] = useState(true);
|
||||
const [passwordLoginAllowed, setPasswordLoginAllowed] = useState(true);
|
||||
const [passwordResetAllowed, setPasswordResetAllowed] = useState(true);
|
||||
const [oidcConfigured, setOidcConfigured] = useState(false);
|
||||
const [oidcConfigLoaded, setOidcConfigLoaded] = useState(false);
|
||||
const [ssoProviders, setSsoProviders] = useState<SSOProviderPublic[]>([]);
|
||||
const [ssoProvidersLoaded, setSsoProvidersLoaded] = useState(false);
|
||||
const [expandedLdapId, setExpandedLdapId] = useState<number | null>(null);
|
||||
const [ldapUsername, setLdapUsername] = useState("");
|
||||
const [ldapPassword, setLdapPassword] = useState("");
|
||||
const silentSigninHandledRef = useRef(false);
|
||||
const [firstUser, setFirstUser] = useState(false);
|
||||
const [dbConnectionFailed, setDbConnectionFailed] = useState(false);
|
||||
@@ -276,10 +284,10 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
getPasswordResetAllowed()
|
||||
.then((allowed) => setPasswordResetAllowed(allowed))
|
||||
.catch(() => setPasswordResetAllowed(false));
|
||||
getOIDCConfig()
|
||||
.then((res) => setOidcConfigured(!!res))
|
||||
.catch(() => setOidcConfigured(false))
|
||||
.finally(() => setOidcConfigLoaded(true));
|
||||
getSSOProviders()
|
||||
.then((providers) => setSsoProviders(providers || []))
|
||||
.catch(() => setSsoProviders([]))
|
||||
.finally(() => setSsoProvidersLoaded(true));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -335,6 +343,18 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
if (view === "totp" && totpInputRef.current) totpInputRef.current.focus();
|
||||
}, [view]);
|
||||
|
||||
useEffect(() => {
|
||||
setLdapUsername("");
|
||||
setLdapPassword("");
|
||||
}, [expandedLdapId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ssoProvidersLoaded) return;
|
||||
if (!passwordLoginAllowed && ssoProviders.length > 0 && view === "login") {
|
||||
setView("external");
|
||||
}
|
||||
}, [ssoProvidersLoaded, passwordLoginAllowed, ssoProviders.length, view]);
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const success = urlParams.get("success");
|
||||
@@ -707,65 +727,113 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleOIDCLogin = useCallback(async () => {
|
||||
setOidcLoading(true);
|
||||
try {
|
||||
if (isElectron()) {
|
||||
const electronAPI = (
|
||||
window as unknown as {
|
||||
electronAPI?: {
|
||||
oidcSystemBrowserAuth?: (
|
||||
authUrl: string,
|
||||
port: number,
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
token?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
};
|
||||
const handleOIDCLogin = useCallback(
|
||||
async (providerId?: number) => {
|
||||
const loadingKey = providerId ?? -1;
|
||||
setProviderLoading((prev) => ({ ...prev, [loadingKey]: true }));
|
||||
try {
|
||||
if (isElectron()) {
|
||||
const electronAPI = (
|
||||
window as unknown as {
|
||||
electronAPI?: {
|
||||
oidcSystemBrowserAuth?: (
|
||||
authUrl: string,
|
||||
port: number,
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
token?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
).electronAPI;
|
||||
if (electronAPI?.oidcSystemBrowserAuth) {
|
||||
const callbackPort = 17832 + Math.floor(Math.random() * 100);
|
||||
const authResponse = await getOIDCAuthorizeUrl(
|
||||
rememberMe,
|
||||
callbackPort,
|
||||
providerId,
|
||||
);
|
||||
const { auth_url: authUrl } = authResponse;
|
||||
if (!authUrl) throw new Error(t("errors.invalidAuthUrl"));
|
||||
const result = await electronAPI.oidcSystemBrowserAuth(
|
||||
authUrl,
|
||||
callbackPort,
|
||||
);
|
||||
if (result.success && result.token) {
|
||||
localStorage.setItem("jwt", result.token);
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
throw new Error(result.error || "Authentication failed");
|
||||
}
|
||||
).electronAPI;
|
||||
if (electronAPI?.oidcSystemBrowserAuth) {
|
||||
const callbackPort = 17832 + Math.floor(Math.random() * 100);
|
||||
const authResponse = await getOIDCAuthorizeUrl(
|
||||
rememberMe,
|
||||
callbackPort,
|
||||
);
|
||||
const { auth_url: authUrl } = authResponse;
|
||||
if (!authUrl) throw new Error(t("errors.invalidAuthUrl"));
|
||||
const result = await electronAPI.oidcSystemBrowserAuth(
|
||||
authUrl,
|
||||
callbackPort,
|
||||
);
|
||||
if (result.success && result.token) {
|
||||
localStorage.setItem("jwt", result.token);
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
throw new Error(result.error || "Authentication failed");
|
||||
}
|
||||
const authResponse = await getOIDCAuthorizeUrl(
|
||||
rememberMe,
|
||||
undefined,
|
||||
providerId,
|
||||
);
|
||||
const { auth_url: authUrl } = authResponse;
|
||||
if (!authUrl || authUrl === "undefined")
|
||||
throw new Error(t("errors.invalidAuthUrl"));
|
||||
window.location.replace(authUrl);
|
||||
} catch (err: unknown) {
|
||||
const error = err as {
|
||||
message?: string;
|
||||
response?: { data?: { error?: string } };
|
||||
};
|
||||
toast.error(
|
||||
error?.response?.data?.error ||
|
||||
error?.message ||
|
||||
t("errors.failedOidcLogin"),
|
||||
);
|
||||
setProviderLoading((prev) => ({ ...prev, [loadingKey]: false }));
|
||||
}
|
||||
const authResponse = await getOIDCAuthorizeUrl(rememberMe);
|
||||
const { auth_url: authUrl } = authResponse;
|
||||
if (!authUrl || authUrl === "undefined")
|
||||
throw new Error(t("errors.invalidAuthUrl"));
|
||||
window.location.replace(authUrl);
|
||||
} catch (err: unknown) {
|
||||
const error = err as {
|
||||
message?: string;
|
||||
response?: { data?: { error?: string } };
|
||||
};
|
||||
toast.error(
|
||||
error?.response?.data?.error ||
|
||||
error?.message ||
|
||||
t("errors.failedOidcLogin"),
|
||||
);
|
||||
setOidcLoading(false);
|
||||
}
|
||||
}, [rememberMe, t]);
|
||||
},
|
||||
[rememberMe, t],
|
||||
);
|
||||
|
||||
const handleLDAPLogin = useCallback(
|
||||
async (providerId: number) => {
|
||||
if (!ldapUsername.trim() || !ldapPassword) {
|
||||
toast.error(t("errors.requiredField"));
|
||||
return;
|
||||
}
|
||||
setProviderLoading((prev) => ({ ...prev, [providerId]: true }));
|
||||
try {
|
||||
await ldapLogin(
|
||||
providerId,
|
||||
ldapUsername.trim(),
|
||||
ldapPassword,
|
||||
rememberMe,
|
||||
);
|
||||
const meRes = await getUserInfo();
|
||||
storeAuth(meRes.username || "");
|
||||
toast.success(t("messages.loginSuccess"));
|
||||
onLogin(
|
||||
meRes.username || "",
|
||||
meRes.userId || undefined,
|
||||
!!meRes.is_admin,
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
const error = err as {
|
||||
response?: { data?: { error?: string } };
|
||||
message?: string;
|
||||
};
|
||||
toast.error(
|
||||
error?.response?.data?.error ||
|
||||
error?.message ||
|
||||
t("auth.ldapLoginFailed"),
|
||||
);
|
||||
} finally {
|
||||
setProviderLoading((prev) => ({ ...prev, [providerId]: false }));
|
||||
}
|
||||
},
|
||||
[ldapUsername, ldapPassword, rememberMe, onLogin, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!oidcConfigLoaded || silentSigninHandledRef.current) return;
|
||||
if (!ssoProvidersLoaded || silentSigninHandledRef.current) return;
|
||||
if (!shouldTriggerSilentSignin(window.location.search)) return;
|
||||
|
||||
const nextSearch = removeSilentSigninFromSearch(window.location.search);
|
||||
@@ -776,13 +844,17 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
);
|
||||
|
||||
silentSigninHandledRef.current = true;
|
||||
if (oidcConfigured && !isElectron()) {
|
||||
handleOIDCLogin();
|
||||
|
||||
const oidcProvider = ssoProviders.find(
|
||||
(p) => p.type === "oidc" || p.type === "github" || p.type === "google",
|
||||
);
|
||||
if (oidcProvider && !isElectron()) {
|
||||
handleOIDCLogin(oidcProvider.id);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.info(t("errors.silentSigninOidcUnavailable"));
|
||||
}, [handleOIDCLogin, oidcConfigLoaded, oidcConfigured, t]);
|
||||
}, [handleOIDCLogin, ssoProvidersLoaded, ssoProviders, t]);
|
||||
|
||||
// Electron server config / webview auth success screens
|
||||
if (isElectron() && !isInElectronWebView()) {
|
||||
@@ -908,7 +980,11 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
label: t("common.register"),
|
||||
show: (passwordLoginAllowed || firstUser) && registrationAllowed,
|
||||
},
|
||||
{ id: "external", label: t("auth.external"), show: oidcConfigured },
|
||||
{
|
||||
id: "external",
|
||||
label: t("auth.external"),
|
||||
show: ssoProviders.length > 0,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -1182,36 +1258,115 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
|
||||
{view === "external" && (
|
||||
<div className="flex flex-col gap-4">
|
||||
{isElectron() ? (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
{t("auth.externalNotSupportedInElectron")}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="rememberOIDC"
|
||||
checked={rememberMe}
|
||||
onCheckedChange={(v) => setRememberMe(v === true)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="rememberOIDC"
|
||||
className="text-xs text-muted-foreground cursor-pointer"
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="rememberSSO"
|
||||
checked={rememberMe}
|
||||
onCheckedChange={(v) => setRememberMe(v === true)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="rememberSSO"
|
||||
className="text-xs text-muted-foreground cursor-pointer"
|
||||
>
|
||||
{t("auth.rememberMe")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{ssoProviders.map((provider) => {
|
||||
const isLoading = !!providerLoading[provider.id];
|
||||
|
||||
if (provider.type === "ldap") {
|
||||
const isExpanded = expandedLdapId === provider.id;
|
||||
return (
|
||||
<div
|
||||
key={provider.id}
|
||||
className="flex flex-col gap-0 border border-border"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setExpandedLdapId(
|
||||
isExpanded ? null : provider.id,
|
||||
)
|
||||
}
|
||||
className="flex items-center justify-between w-full px-3 py-2.5 text-xs font-bold uppercase tracking-widest text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<span>
|
||||
{t("auth.loginWithProvider", {
|
||||
name: provider.name,
|
||||
})}
|
||||
</span>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="size-3.5" />
|
||||
) : (
|
||||
<ChevronDown className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleLDAPLogin(provider.id);
|
||||
}}
|
||||
className="flex flex-col gap-3 p-3 border-t border-border"
|
||||
>
|
||||
<Field
|
||||
label={t("auth.ldapUsername")}
|
||||
htmlFor={`ldap-user-${provider.id}`}
|
||||
>
|
||||
<Input
|
||||
id={`ldap-user-${provider.id}`}
|
||||
value={ldapUsername}
|
||||
onChange={(e) =>
|
||||
setLdapUsername(e.target.value)
|
||||
}
|
||||
disabled={isLoading}
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("auth.ldapPassword")}
|
||||
htmlFor={`ldap-pass-${provider.id}`}
|
||||
>
|
||||
<PasswordInput
|
||||
id={`ldap-pass-${provider.id}`}
|
||||
value={ldapPassword}
|
||||
onChange={setLdapPassword}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</Field>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-accent-brand hover:bg-accent-brand/90 text-background font-bold"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading
|
||||
? t("common.loading")
|
||||
: t("auth.ldapSignIn")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={provider.id}
|
||||
onClick={() => handleOIDCLogin(provider.id)}
|
||||
disabled={isLoading}
|
||||
className="w-full bg-accent-brand hover:bg-accent-brand/90 text-background font-bold"
|
||||
>
|
||||
{t("auth.rememberMe")}
|
||||
</label>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleOIDCLogin}
|
||||
disabled={oidcLoading}
|
||||
className="w-full bg-accent-brand hover:bg-accent-brand/90 text-background font-bold"
|
||||
>
|
||||
{oidcLoading
|
||||
? t("common.loading")
|
||||
: t("auth.loginWithExternal")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{isLoading
|
||||
? t("common.loading")
|
||||
: t("auth.loginWithProvider", {
|
||||
name: provider.name,
|
||||
})}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
+362
-291
@@ -18,7 +18,6 @@ import {
|
||||
getUserInfo,
|
||||
getRegistrationAllowed,
|
||||
getPasswordLoginAllowed,
|
||||
getOIDCConfig,
|
||||
getSetupRequired,
|
||||
initiatePasswordReset,
|
||||
verifyPasswordResetCode,
|
||||
@@ -31,6 +30,8 @@ import {
|
||||
getEmbeddedServerStatus,
|
||||
getCurrentToken,
|
||||
} from "@/main-axios";
|
||||
import { getSSOProviders, ldapLogin } from "@/api/sso-provider-api";
|
||||
import type { SSOProviderPublic } from "@/types/index";
|
||||
import { ElectronServerConfig as ServerConfigComponent } from "@/auth/ElectronServerConfig.tsx";
|
||||
import { ElectronLoginForm } from "@/auth/ElectronLoginForm.tsx";
|
||||
import {
|
||||
@@ -102,9 +103,7 @@ export function Auth({
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
const [tab, setTab] = useState<"login" | "signup" | "external" | "reset">(
|
||||
"login",
|
||||
);
|
||||
const [tab, setTab] = useState<"login" | "signup" | "reset">("login");
|
||||
const [localUsername, setLocalUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [signupConfirmPassword, setSignupConfirmPassword] = useState("");
|
||||
@@ -123,8 +122,12 @@ export function Auth({
|
||||
const [firstUserToastShown, setFirstUserToastShown] = useState(false);
|
||||
const [registrationAllowed, setRegistrationAllowed] = useState(true);
|
||||
const [passwordLoginAllowed, setPasswordLoginAllowed] = useState(true);
|
||||
const [oidcConfigured, setOidcConfigured] = useState(false);
|
||||
const [oidcConfigLoaded, setOidcConfigLoaded] = useState(false);
|
||||
const [ssoProviders, setSsoProviders] = useState<SSOProviderPublic[]>([]);
|
||||
const [ssoProvidersLoaded, setSsoProvidersLoaded] = useState(false);
|
||||
const [ldapProviderId, setLdapProviderId] = useState<number | null>(null);
|
||||
const [ldapUsername, setLdapUsername] = useState("");
|
||||
const [ldapPassword, setLdapPassword] = useState("");
|
||||
const [ldapLoading, setLdapLoading] = useState(false);
|
||||
const silentSigninHandledRef = useRef(false);
|
||||
|
||||
const [resetStep, setResetStep] = useState<
|
||||
@@ -243,23 +246,15 @@ export function Auth({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getOIDCConfig()
|
||||
.then((response) => {
|
||||
if (response) {
|
||||
setOidcConfigured(true);
|
||||
} else {
|
||||
setOidcConfigured(false);
|
||||
}
|
||||
getSSOProviders()
|
||||
.then((providers) => {
|
||||
setSsoProviders(providers || []);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.response?.status === 404) {
|
||||
setOidcConfigured(false);
|
||||
} else {
|
||||
setOidcConfigured(false);
|
||||
}
|
||||
.catch(() => {
|
||||
setSsoProviders([]);
|
||||
})
|
||||
.finally(() => {
|
||||
setOidcConfigLoaded(true);
|
||||
setSsoProvidersLoaded(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -292,11 +287,8 @@ export function Auth({
|
||||
});
|
||||
}, [setDbError, firstUserToastShown, showServerConfig, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!passwordLoginAllowed && oidcConfigured && tab !== "external") {
|
||||
setTab("external");
|
||||
}
|
||||
}, [passwordLoginAllowed, oidcConfigured, tab]);
|
||||
// When password login is disabled and SSO is available, stay on login tab
|
||||
// (SSO buttons appear below the form regardless of tab)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -650,33 +642,68 @@ export function Auth({
|
||||
}
|
||||
}
|
||||
|
||||
const handleOIDCLogin = useCallback(async () => {
|
||||
setOidcLoading(true);
|
||||
try {
|
||||
const authResponse = await getOIDCAuthorizeUrl(rememberMe);
|
||||
const { auth_url: authUrl } = authResponse;
|
||||
const handleOIDCLogin = useCallback(
|
||||
async (providerId?: number) => {
|
||||
setOidcLoading(true);
|
||||
try {
|
||||
const authResponse = await getOIDCAuthorizeUrl(
|
||||
rememberMe,
|
||||
undefined,
|
||||
providerId,
|
||||
);
|
||||
const { auth_url: authUrl } = authResponse;
|
||||
|
||||
if (!authUrl || authUrl === "undefined") {
|
||||
throw new Error(t("errors.invalidAuthUrl"));
|
||||
if (!authUrl || authUrl === "undefined") {
|
||||
throw new Error(t("errors.invalidAuthUrl"));
|
||||
}
|
||||
|
||||
window.location.replace(authUrl);
|
||||
} catch (err: unknown) {
|
||||
const error = err as {
|
||||
message?: string;
|
||||
response?: { data?: { error?: string } };
|
||||
};
|
||||
const errorMessage =
|
||||
error?.response?.data?.error ||
|
||||
error?.message ||
|
||||
t("errors.failedOidcLogin");
|
||||
toast.error(errorMessage);
|
||||
setOidcLoading(false);
|
||||
}
|
||||
},
|
||||
[rememberMe, t],
|
||||
);
|
||||
|
||||
window.location.replace(authUrl);
|
||||
} catch (err: unknown) {
|
||||
const error = err as {
|
||||
message?: string;
|
||||
response?: { data?: { error?: string } };
|
||||
};
|
||||
const errorMessage =
|
||||
error?.response?.data?.error ||
|
||||
error?.message ||
|
||||
t("errors.failedOidcLogin");
|
||||
toast.error(errorMessage);
|
||||
setOidcLoading(false);
|
||||
}
|
||||
}, [rememberMe, t]);
|
||||
const handleLDAPLogin = useCallback(
|
||||
async (providerId: number) => {
|
||||
if (!ldapUsername.trim() || !ldapPassword) {
|
||||
toast.error(t("errors.requiredField"));
|
||||
return;
|
||||
}
|
||||
setLdapLoading(true);
|
||||
try {
|
||||
await ldapLogin(providerId, ldapUsername, ldapPassword, rememberMe);
|
||||
const userInfo = await getUserInfo();
|
||||
onLogin(userInfo);
|
||||
} catch (err: unknown) {
|
||||
const error = err as {
|
||||
response?: { data?: { error?: string } };
|
||||
message?: string;
|
||||
};
|
||||
toast.error(
|
||||
error?.response?.data?.error ||
|
||||
error?.message ||
|
||||
t("auth.ldapLoginFailed"),
|
||||
);
|
||||
} finally {
|
||||
setLdapLoading(false);
|
||||
}
|
||||
},
|
||||
[ldapUsername, ldapPassword, rememberMe, onLogin, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!oidcConfigLoaded || silentSigninHandledRef.current) return;
|
||||
if (!ssoProvidersLoaded || silentSigninHandledRef.current) return;
|
||||
if (!shouldTriggerSilentSignin(window.location.search)) return;
|
||||
|
||||
const nextSearch = removeSilentSigninFromSearch(window.location.search);
|
||||
@@ -687,13 +714,22 @@ export function Auth({
|
||||
);
|
||||
|
||||
silentSigninHandledRef.current = true;
|
||||
if (oidcConfigured && !isElectron()) {
|
||||
handleOIDCLogin();
|
||||
const oidcProvider = ssoProviders.find(
|
||||
(p) => p.type === "oidc" || p.type === "github" || p.type === "google",
|
||||
);
|
||||
if (oidcProvider && !isElectron()) {
|
||||
handleOIDCLogin(oidcProvider.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ssoProviders.length > 0 && !isElectron()) {
|
||||
const first = ssoProviders[0];
|
||||
if (first.type !== "ldap") handleOIDCLogin(first.id);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.info(t("errors.silentSigninOidcUnavailable"));
|
||||
}, [handleOIDCLogin, oidcConfigLoaded, oidcConfigured, t]);
|
||||
}, [handleOIDCLogin, ssoProvidersLoaded, ssoProviders, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
@@ -1233,8 +1269,8 @@ export function Auth({
|
||||
const hasSignup =
|
||||
(passwordLoginAllowed || firstUser) &&
|
||||
registrationAllowed;
|
||||
const hasOIDC = oidcConfigured;
|
||||
const hasAnyAuth = hasLogin || hasSignup || hasOIDC;
|
||||
const hasSso = ssoProviders.length > 0;
|
||||
const hasAnyAuth = hasLogin || hasSignup || hasSso;
|
||||
|
||||
if (!hasAnyAuth) {
|
||||
return (
|
||||
@@ -1253,12 +1289,8 @@ export function Auth({
|
||||
<>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(value) => {
|
||||
const newTab = value as
|
||||
| "login"
|
||||
| "signup"
|
||||
| "external"
|
||||
| "reset";
|
||||
onValueChange={(v) => {
|
||||
const newTab = v as "login" | "signup" | "reset";
|
||||
setTab(newTab);
|
||||
if (tab === "reset") resetPasswordState();
|
||||
if (
|
||||
@@ -1290,15 +1322,6 @@ export function Auth({
|
||||
{t("common.register")}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{oidcConfigured && (
|
||||
<TabsTrigger
|
||||
value="external"
|
||||
disabled={oidcLoading}
|
||||
className="flex-1"
|
||||
>
|
||||
{t("auth.external")}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
@@ -1308,241 +1331,182 @@ export function Auth({
|
||||
? t("auth.loginTitle")
|
||||
: tab === "signup"
|
||||
? t("auth.registerTitle")
|
||||
: tab === "external"
|
||||
? t("auth.loginWithExternal")
|
||||
: t("auth.forgotPassword")}
|
||||
: t("auth.forgotPassword")}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{tab === "external" || tab === "reset" ? (
|
||||
{tab === "reset" ? (
|
||||
<div className="flex flex-col gap-5">
|
||||
{tab === "external" && (
|
||||
{resetStep === "initiate" && (
|
||||
<>
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTitle>{t("common.warning")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("auth.dataLossWarning")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>{t("auth.loginWithExternalDesc")}</p>
|
||||
<p>{t("auth.resetCodeDesc")}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="reset-username">
|
||||
{t("common.username")}
|
||||
</Label>
|
||||
<Input
|
||||
id="reset-username"
|
||||
type="text"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={localUsername}
|
||||
onChange={(e) =>
|
||||
setLocalUsername(e.target.value)
|
||||
}
|
||||
disabled={resetLoading}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={
|
||||
resetLoading || !localUsername.trim()
|
||||
}
|
||||
onClick={handleInitiatePasswordReset}
|
||||
>
|
||||
{resetLoading
|
||||
? Spinner
|
||||
: t("auth.sendResetCode")}
|
||||
</Button>
|
||||
</div>
|
||||
{(() => {
|
||||
if (isElectron()) {
|
||||
return (
|
||||
<div className="text-center p-4 bg-muted/50 rounded-lg border">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(
|
||||
"auth.externalNotSupportedInElectron",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="rememberMeOIDC"
|
||||
checked={rememberMe}
|
||||
onCheckedChange={(checked) =>
|
||||
setRememberMe(checked === true)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="rememberMeOIDC">
|
||||
{t("auth.rememberMe")}
|
||||
</Label>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 mt-2 text-base font-semibold"
|
||||
disabled={oidcLoading}
|
||||
onClick={handleOIDCLogin}
|
||||
>
|
||||
{oidcLoading
|
||||
? Spinner
|
||||
: t("auth.loginWithExternal")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
{tab === "reset" && (
|
||||
|
||||
{resetStep === "verify" && (
|
||||
<>
|
||||
{resetStep === "initiate" && (
|
||||
<>
|
||||
<Alert
|
||||
variant="destructive"
|
||||
className="mb-4"
|
||||
>
|
||||
<AlertTitle>
|
||||
{t("common.warning")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("auth.dataLossWarning")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>{t("auth.resetCodeDesc")}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="reset-username">
|
||||
{t("common.username")}
|
||||
</Label>
|
||||
<Input
|
||||
id="reset-username"
|
||||
type="text"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={localUsername}
|
||||
onChange={(e) =>
|
||||
setLocalUsername(e.target.value)
|
||||
}
|
||||
disabled={resetLoading}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={
|
||||
resetLoading || !localUsername.trim()
|
||||
}
|
||||
onClick={handleInitiatePasswordReset}
|
||||
>
|
||||
{resetLoading
|
||||
? Spinner
|
||||
: t("auth.sendResetCode")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>
|
||||
{t("auth.enterResetCode")}{" "}
|
||||
<strong>{localUsername}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="reset-code">
|
||||
{t("auth.resetCode")}
|
||||
</Label>
|
||||
<Input
|
||||
id="reset-code"
|
||||
type="text"
|
||||
required
|
||||
maxLength={6}
|
||||
className="h-11 text-base text-center text-lg tracking-widest"
|
||||
value={resetCode}
|
||||
onChange={(e) =>
|
||||
setResetCode(
|
||||
e.target.value.replace(/\D/g, ""),
|
||||
)
|
||||
}
|
||||
disabled={resetLoading}
|
||||
placeholder="000000"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={
|
||||
resetLoading || resetCode.length !== 6
|
||||
}
|
||||
onClick={handleVerifyResetCode}
|
||||
>
|
||||
{resetLoading
|
||||
? Spinner
|
||||
: t("auth.verifyCodeButton")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading}
|
||||
onClick={() => {
|
||||
setResetStep("initiate");
|
||||
setResetCode("");
|
||||
}}
|
||||
>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{resetStep === "verify" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>
|
||||
{t("auth.enterResetCode")}{" "}
|
||||
<strong>{localUsername}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="reset-code">
|
||||
{t("auth.resetCode")}
|
||||
</Label>
|
||||
<Input
|
||||
id="reset-code"
|
||||
type="text"
|
||||
required
|
||||
maxLength={6}
|
||||
className="h-11 text-base text-center text-lg tracking-widest"
|
||||
value={resetCode}
|
||||
onChange={(e) =>
|
||||
setResetCode(
|
||||
e.target.value.replace(/\D/g, ""),
|
||||
)
|
||||
}
|
||||
disabled={resetLoading}
|
||||
placeholder="000000"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={
|
||||
resetLoading || resetCode.length !== 6
|
||||
}
|
||||
onClick={handleVerifyResetCode}
|
||||
>
|
||||
{resetLoading
|
||||
? Spinner
|
||||
: t("auth.verifyCodeButton")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading}
|
||||
onClick={() => {
|
||||
setResetStep("initiate");
|
||||
setResetCode("");
|
||||
}}
|
||||
>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{resetStep === "newPassword" &&
|
||||
!resetSuccess && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>
|
||||
{t("auth.enterNewPassword")}{" "}
|
||||
<strong>{localUsername}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="new-p assword">
|
||||
{t("auth.newPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="new-password"
|
||||
required
|
||||
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
||||
value={newPassword}
|
||||
onChange={(e) =>
|
||||
setNewPassword(e.target.value)
|
||||
}
|
||||
disabled={resetLoading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="confirm-password">
|
||||
{t("auth.confirmNewPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="confirm-password"
|
||||
required
|
||||
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
||||
value={confirmPassword}
|
||||
onChange={(e) =>
|
||||
setConfirmPassword(e.target.value)
|
||||
}
|
||||
disabled={resetLoading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={
|
||||
resetLoading ||
|
||||
!newPassword ||
|
||||
!confirmPassword
|
||||
}
|
||||
onClick={handleCompletePasswordReset}
|
||||
>
|
||||
{resetLoading
|
||||
? Spinner
|
||||
: t("auth.resetPasswordButton")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading}
|
||||
onClick={() => {
|
||||
setResetStep("verify");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
}}
|
||||
>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{resetStep === "newPassword" && !resetSuccess && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>
|
||||
{t("auth.enterNewPassword")}{" "}
|
||||
<strong>{localUsername}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="new-p assword">
|
||||
{t("auth.newPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="new-password"
|
||||
required
|
||||
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
||||
value={newPassword}
|
||||
onChange={(e) =>
|
||||
setNewPassword(e.target.value)
|
||||
}
|
||||
disabled={resetLoading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="confirm-password">
|
||||
{t("auth.confirmNewPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="confirm-password"
|
||||
required
|
||||
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
||||
value={confirmPassword}
|
||||
onChange={(e) =>
|
||||
setConfirmPassword(e.target.value)
|
||||
}
|
||||
disabled={resetLoading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={
|
||||
resetLoading ||
|
||||
!newPassword ||
|
||||
!confirmPassword
|
||||
}
|
||||
onClick={handleCompletePasswordReset}
|
||||
>
|
||||
{resetLoading
|
||||
? Spinner
|
||||
: t("auth.resetPasswordButton")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading}
|
||||
onClick={() => {
|
||||
setResetStep("verify");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
}}
|
||||
>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -1641,6 +1605,113 @@ export function Auth({
|
||||
{t("auth.resetPasswordButton")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{ssoProviders.length > 0 && !isElectron() && (
|
||||
<div className="flex flex-col gap-3 pt-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 border-t border-border" />
|
||||
<span className="text-xs text-muted-foreground px-1 whitespace-nowrap">
|
||||
{t("auth.orContinueWith")}
|
||||
</span>
|
||||
<div className="flex-1 border-t border-border" />
|
||||
</div>
|
||||
{ssoProviders.map((provider) => {
|
||||
if (provider.type === "ldap") {
|
||||
const isExpanded =
|
||||
ldapProviderId === provider.id;
|
||||
return (
|
||||
<div
|
||||
key={provider.id}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-10 text-sm font-medium"
|
||||
onClick={() =>
|
||||
setLdapProviderId(
|
||||
isExpanded ? null : provider.id,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("auth.loginWithProvider", {
|
||||
name: provider.name,
|
||||
})}
|
||||
</Button>
|
||||
{isExpanded && (
|
||||
<div className="flex flex-col gap-2 p-3 border border-border rounded-md bg-muted/30">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t(
|
||||
"auth.ldapUsername",
|
||||
)}
|
||||
value={ldapUsername}
|
||||
onChange={(e) =>
|
||||
setLdapUsername(e.target.value)
|
||||
}
|
||||
className="h-9 text-sm"
|
||||
disabled={ldapLoading}
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t(
|
||||
"auth.ldapPassword",
|
||||
)}
|
||||
value={ldapPassword}
|
||||
onChange={(e) =>
|
||||
setLdapPassword(e.target.value)
|
||||
}
|
||||
className="h-9 text-sm"
|
||||
disabled={ldapLoading}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter")
|
||||
handleLDAPLogin(provider.id);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-9 text-sm font-medium"
|
||||
disabled={
|
||||
ldapLoading ||
|
||||
!ldapUsername.trim() ||
|
||||
!ldapPassword
|
||||
}
|
||||
onClick={() =>
|
||||
handleLDAPLogin(provider.id)
|
||||
}
|
||||
>
|
||||
{ldapLoading
|
||||
? Spinner
|
||||
: t("auth.loginWithProvider", {
|
||||
name: provider.name,
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={provider.id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-10 text-sm font-medium"
|
||||
disabled={oidcLoading}
|
||||
onClick={() =>
|
||||
handleOIDCLogin(provider.id)
|
||||
}
|
||||
>
|
||||
{oidcLoading
|
||||
? Spinner
|
||||
: t("auth.loginWithProvider", {
|
||||
name: provider.name,
|
||||
})}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import React, { useState } from "react";
|
||||
import { Auth } from "@/auth/LoginPage.tsx";
|
||||
|
||||
interface LoginScreenProps {
|
||||
authLoading: boolean;
|
||||
onAuthSuccess: (authData: {
|
||||
isAdmin: boolean;
|
||||
username: string | null;
|
||||
userId: string | null;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export function LoginScreen({
|
||||
authLoading,
|
||||
onAuthSuccess,
|
||||
}: LoginScreenProps): React.ReactElement {
|
||||
const [loggedIn, setLoggedIn] = useState(false);
|
||||
const [, setIsAdmin] = useState(false);
|
||||
const [, setUsername] = useState<string | null>(null);
|
||||
const [, setUserId] = useState<string | null>(null);
|
||||
const [, setDbError] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Auth
|
||||
setLoggedIn={setLoggedIn}
|
||||
setIsAdmin={setIsAdmin}
|
||||
setUsername={setUsername}
|
||||
setUserId={setUserId}
|
||||
loggedIn={loggedIn}
|
||||
authLoading={authLoading}
|
||||
setDbError={setDbError}
|
||||
onAuthSuccess={onAuthSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
type DragEvent,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
GripVertical,
|
||||
Trash2,
|
||||
Plus,
|
||||
Columns2,
|
||||
StretchHorizontal,
|
||||
} from "lucide-react";
|
||||
import type { GridSlot, GridCardCatalogEntry } from "./types";
|
||||
import {
|
||||
MAX_COLUMNS,
|
||||
MIN_COLUMNS,
|
||||
clampColumns,
|
||||
nextColSpan,
|
||||
sortSlots,
|
||||
reorderSlots,
|
||||
addSlot,
|
||||
removeSlot,
|
||||
setColSpan,
|
||||
setHeight,
|
||||
heightToRowSpan,
|
||||
} from "./layout-utils";
|
||||
|
||||
type DragState = { id: string; order: number } | null;
|
||||
|
||||
/**
|
||||
* Masonry is implemented with the CSS-grid row-span trick: the grid has many
|
||||
* tiny rows (`ROW_UNIT`px each) and every tile spans `ceil(height / ROW_UNIT)`
|
||||
* rows. With `grid-auto-flow: row dense` the browser packs short tiles under
|
||||
* tall ones (tetris) while still honouring multi-column spans natively.
|
||||
*/
|
||||
const ROW_UNIT = 8;
|
||||
const ROW_GAP = 12;
|
||||
|
||||
function DropZone({
|
||||
order,
|
||||
active,
|
||||
onDropAt,
|
||||
onDragOver,
|
||||
}: {
|
||||
order: number;
|
||||
active: boolean;
|
||||
onDropAt: (order: number) => void;
|
||||
onDragOver: (e: DragEvent) => void;
|
||||
}) {
|
||||
const [over, setOver] = useState(false);
|
||||
if (!active) return null;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"transition-all duration-150",
|
||||
over
|
||||
? "h-16 border-2 border-dashed border-accent-brand/60 bg-accent-brand/5"
|
||||
: "h-3",
|
||||
)}
|
||||
onDragOver={(e) => {
|
||||
onDragOver(e);
|
||||
setOver(true);
|
||||
}}
|
||||
onDragLeave={() => setOver(false)}
|
||||
onDrop={() => {
|
||||
setOver(false);
|
||||
onDropAt(order);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TileChrome({
|
||||
columns,
|
||||
label,
|
||||
onRemove,
|
||||
onCycleWidth,
|
||||
onResizeStart,
|
||||
}: {
|
||||
columns: number;
|
||||
label: string;
|
||||
onRemove: () => void;
|
||||
onCycleWidth: () => void;
|
||||
onResizeStart: (e: ReactMouseEvent) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<div className="pointer-events-none absolute inset-0 z-10 border-2 border-dashed border-accent-brand/30" />
|
||||
<div className="absolute top-2 right-2 z-20 flex items-center gap-1">
|
||||
<div
|
||||
className="flex size-6 cursor-grab items-center justify-center border border-border bg-card active:cursor-grabbing"
|
||||
title={t("cardGrid.dragToMove")}
|
||||
>
|
||||
<GripVertical className="size-3 text-muted-foreground" />
|
||||
</div>
|
||||
{columns > 1 && (
|
||||
<button
|
||||
onClick={onCycleWidth}
|
||||
className="flex size-6 items-center justify-center border border-border bg-card transition-colors hover:border-accent-brand/40 hover:bg-accent-brand/10"
|
||||
title={t("cardGrid.changeWidth")}
|
||||
>
|
||||
<Columns2 className="size-3 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="flex size-6 items-center justify-center border border-border bg-card transition-colors hover:border-destructive/40 hover:bg-destructive/10"
|
||||
title={t("cardGrid.removeCard", { label })}
|
||||
>
|
||||
<Trash2 className="size-3 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
onMouseDown={onResizeStart}
|
||||
className="group/resize absolute bottom-0 left-0 right-0 z-20 flex h-2.5 cursor-row-resize items-center justify-center"
|
||||
title={t("cardGrid.dragToResize")}
|
||||
>
|
||||
<div className="h-0.5 w-12 rounded-full bg-border transition-colors group-hover/resize:bg-accent-brand/60" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AddTray({
|
||||
catalog,
|
||||
presentIds,
|
||||
onAdd,
|
||||
}: {
|
||||
catalog: GridCardCatalogEntry[];
|
||||
presentIds: Set<string>;
|
||||
onAdd: (entry: GridCardCatalogEntry) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const available = catalog.filter((c) => !presentIds.has(c.id));
|
||||
if (available.length === 0) return null;
|
||||
return (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("cardGrid.addCard")}
|
||||
</span>
|
||||
{available.map((card) => (
|
||||
<button
|
||||
key={card.id}
|
||||
onClick={() => onAdd(card)}
|
||||
title={card.description}
|
||||
className="flex items-center gap-1.5 border border-dashed border-border px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:border-accent-brand/60 hover:bg-accent-brand/5 hover:text-foreground"
|
||||
>
|
||||
<Plus className="size-3 text-accent-brand" />
|
||||
{card.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One masonry tile. Measures its natural (or overridden) height and reports the
|
||||
* number of grid rows it should span so the parent grid can pack densely.
|
||||
*/
|
||||
function MasonryTile({
|
||||
slot,
|
||||
span,
|
||||
rows,
|
||||
editMode,
|
||||
isDragging,
|
||||
effectiveColumns,
|
||||
label,
|
||||
measureRef,
|
||||
children,
|
||||
onReportRows,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
onRemove,
|
||||
onCycleWidth,
|
||||
onResizeStart,
|
||||
}: {
|
||||
slot: GridSlot;
|
||||
span: number;
|
||||
rows: number;
|
||||
editMode: boolean;
|
||||
isDragging: boolean;
|
||||
effectiveColumns: number;
|
||||
label: string;
|
||||
measureRef: (el: HTMLDivElement | null) => void;
|
||||
children: ReactNode;
|
||||
onReportRows: (rows: number) => void;
|
||||
onDragStart: () => void;
|
||||
onDragEnd: () => void;
|
||||
onDragOver: (e: DragEvent) => void;
|
||||
onDrop: () => void;
|
||||
onRemove: () => void;
|
||||
onCycleWidth: () => void;
|
||||
onResizeStart: (e: ReactMouseEvent) => void;
|
||||
}) {
|
||||
const innerRef = useRef<HTMLDivElement | null>(null);
|
||||
const fixedHeight = slot.height ?? undefined;
|
||||
|
||||
// Report the span (in row units) from the measured content height. When an
|
||||
// explicit height override is set we derive rows from it directly.
|
||||
useLayoutEffect(() => {
|
||||
const el = innerRef.current;
|
||||
if (!el) return;
|
||||
const report = () => {
|
||||
const h = fixedHeight ?? el.getBoundingClientRect().height;
|
||||
onReportRows(heightToRowSpan(h, ROW_UNIT, ROW_GAP));
|
||||
};
|
||||
report();
|
||||
if (fixedHeight != null) return;
|
||||
const ro = new ResizeObserver(report);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fixedHeight, span, effectiveColumns]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={measureRef}
|
||||
draggable={editMode}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
className={cn(
|
||||
"relative flex min-w-0 flex-col transition-opacity",
|
||||
editMode && "select-none",
|
||||
isDragging ? "opacity-40" : "opacity-100",
|
||||
)}
|
||||
style={{
|
||||
gridColumn: `span ${span} / span ${span}`,
|
||||
gridRow: `span ${rows} / span ${rows}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={innerRef}
|
||||
className={cn(
|
||||
"min-h-0",
|
||||
fixedHeight != null && "overflow-hidden [&>*]:h-full",
|
||||
)}
|
||||
style={fixedHeight != null ? { height: fixedHeight } : undefined}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{editMode && (
|
||||
<TileChrome
|
||||
columns={effectiveColumns}
|
||||
label={label}
|
||||
onRemove={onRemove}
|
||||
onCycleWidth={onCycleWidth}
|
||||
onResizeStart={onResizeStart}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CardGridCanvasProps {
|
||||
slots: GridSlot[];
|
||||
columns: number;
|
||||
editMode: boolean;
|
||||
/** Render the card body for a given slot id. */
|
||||
renderCard: (id: string) => ReactNode;
|
||||
/** Cards available to add in edit mode (already-present ones are filtered out). */
|
||||
cardCatalog: GridCardCatalogEntry[];
|
||||
onChange: (slots: GridSlot[], columns: number) => void;
|
||||
/** Human label lookup for a card id (used by remove/add chrome). */
|
||||
cardLabel?: (id: string) => string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CardGridCanvas({
|
||||
slots,
|
||||
columns,
|
||||
editMode,
|
||||
renderCard,
|
||||
cardCatalog,
|
||||
onChange,
|
||||
cardLabel,
|
||||
className,
|
||||
}: CardGridCanvasProps) {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
const [dragState, setDragState] = useState<DragState>(null);
|
||||
const [rowSpans, setRowSpans] = useState<Record<string, number>>({});
|
||||
const tileRefs = useRef<Map<string, HTMLDivElement | null>>(new Map());
|
||||
|
||||
const sorted = sortSlots(slots);
|
||||
const presentIds = new Set(slots.map((s) => s.id));
|
||||
const labelFor = useCallback(
|
||||
(id: string) => cardLabel?.(id) ?? id,
|
||||
[cardLabel],
|
||||
);
|
||||
|
||||
// Drop row-span entries for cards that are no longer present.
|
||||
useEffect(() => {
|
||||
setRowSpans((prev) => {
|
||||
const next: Record<string, number> = {};
|
||||
let changed = false;
|
||||
for (const s of slots) {
|
||||
if (prev[s.id] != null) next[s.id] = prev[s.id];
|
||||
else changed = true;
|
||||
}
|
||||
if (Object.keys(next).length !== Object.keys(prev).length) changed = true;
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [slots]);
|
||||
|
||||
const reportRows = useCallback((id: string, rows: number) => {
|
||||
setRowSpans((prev) => (prev[id] === rows ? prev : { ...prev, [id]: rows }));
|
||||
}, []);
|
||||
|
||||
const commitSlots = useCallback(
|
||||
(next: GridSlot[]) => onChange(next, columns),
|
||||
[onChange, columns],
|
||||
);
|
||||
|
||||
const handleDropAt = useCallback(
|
||||
(targetOrder: number) => {
|
||||
if (!dragState) return;
|
||||
commitSlots(reorderSlots(slots, dragState.id, targetOrder));
|
||||
setDragState(null);
|
||||
},
|
||||
[dragState, slots, commitSlots],
|
||||
);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(id: string) => commitSlots(removeSlot(slots, id)),
|
||||
[slots, commitSlots],
|
||||
);
|
||||
|
||||
const handleAdd = useCallback(
|
||||
(entry: GridCardCatalogEntry) =>
|
||||
commitSlots(addSlot(slots, entry, columns)),
|
||||
[slots, columns, commitSlots],
|
||||
);
|
||||
|
||||
const handleCycleWidth = useCallback(
|
||||
(id: string) => {
|
||||
const slot = slots.find((s) => s.id === id);
|
||||
if (!slot) return;
|
||||
commitSlots(setColSpan(slots, id, nextColSpan(slot.colSpan, columns)));
|
||||
},
|
||||
[slots, columns, commitSlots],
|
||||
);
|
||||
|
||||
const handleResizeStart = useCallback(
|
||||
(id: string, e: ReactMouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const startY = e.clientY;
|
||||
const startH =
|
||||
tileRefs.current.get(id)?.getBoundingClientRect().height ?? 120;
|
||||
const onMove = (ev: globalThis.MouseEvent) => {
|
||||
onChange(setHeight(slots, id, startH + (ev.clientY - startY)), columns);
|
||||
};
|
||||
const onUp = () => {
|
||||
window.removeEventListener("mousemove", onMove);
|
||||
window.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
window.addEventListener("mousemove", onMove);
|
||||
window.addEventListener("mouseup", onUp);
|
||||
},
|
||||
[slots, columns, onChange],
|
||||
);
|
||||
|
||||
const handleDragOver = (e: DragEvent) => e.preventDefault();
|
||||
|
||||
// Mobile: simple single-column stack, no DnD / resize / edit chrome.
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-3", className)}>
|
||||
{sorted.map((slot) => (
|
||||
<div key={slot.id} className="w-full">
|
||||
{renderCard(slot.id)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const effectiveColumns = clampColumns(columns);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col", className)}>
|
||||
<div
|
||||
className="grid items-start"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${effectiveColumns}, minmax(0, 1fr))`,
|
||||
gridAutoRows: `${ROW_UNIT}px`,
|
||||
gridAutoFlow: "row dense",
|
||||
columnGap: `${ROW_GAP}px`,
|
||||
rowGap: `${ROW_GAP}px`,
|
||||
}}
|
||||
>
|
||||
{sorted.map((slot) => {
|
||||
const span = Math.min(slot.colSpan, effectiveColumns);
|
||||
const isDragging = dragState?.id === slot.id;
|
||||
const rows = rowSpans[slot.id] ?? 24;
|
||||
return (
|
||||
<MasonryTile
|
||||
key={slot.id}
|
||||
slot={slot}
|
||||
span={span}
|
||||
rows={rows}
|
||||
editMode={editMode}
|
||||
isDragging={isDragging}
|
||||
effectiveColumns={effectiveColumns}
|
||||
label={labelFor(slot.id)}
|
||||
measureRef={(el) => {
|
||||
tileRefs.current.set(slot.id, el);
|
||||
}}
|
||||
onReportRows={(r) => reportRows(slot.id, r)}
|
||||
onDragStart={() =>
|
||||
setDragState({ id: slot.id, order: slot.order })
|
||||
}
|
||||
onDragEnd={() => setDragState(null)}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={() => handleDropAt(slot.order)}
|
||||
onRemove={() => handleRemove(slot.id)}
|
||||
onCycleWidth={() => handleCycleWidth(slot.id)}
|
||||
onResizeStart={(e) => handleResizeStart(slot.id, e)}
|
||||
>
|
||||
{renderCard(slot.id)}
|
||||
</MasonryTile>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{editMode && (
|
||||
<>
|
||||
{sorted.length === 0 && (
|
||||
<div className="flex items-center justify-center border border-dashed border-border/40 py-10 text-xs text-muted-foreground/40">
|
||||
{t("cardGrid.empty")}
|
||||
</div>
|
||||
)}
|
||||
<AddTray
|
||||
catalog={cardCatalog}
|
||||
presentIds={presentIds}
|
||||
onAdd={handleAdd}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Drop target covering the end of the grid while dragging. */}
|
||||
{editMode && (
|
||||
<DropZone
|
||||
order={sorted.length}
|
||||
active={!!dragState}
|
||||
onDropAt={handleDropAt}
|
||||
onDragOver={handleDragOver}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Small stepper to change the masonry column count, for an edit toolbar. */
|
||||
export function ColumnCountStepper({
|
||||
columns,
|
||||
onChange,
|
||||
}: {
|
||||
columns: number;
|
||||
onChange: (columns: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StretchHorizontal className="size-3.5 text-muted-foreground" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("cardGrid.columns")}
|
||||
</span>
|
||||
<div className="flex items-center border border-border">
|
||||
<button
|
||||
className="flex size-6 items-center justify-center text-muted-foreground transition-colors hover:bg-muted disabled:opacity-30"
|
||||
disabled={columns <= MIN_COLUMNS}
|
||||
onClick={() => onChange(clampColumns(columns - 1))}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="w-6 text-center text-xs font-bold tabular-nums">
|
||||
{columns}
|
||||
</span>
|
||||
<button
|
||||
className="flex size-6 items-center justify-center text-muted-foreground transition-colors hover:bg-muted disabled:opacity-30"
|
||||
disabled={columns >= MAX_COLUMNS}
|
||||
onClick={() => onChange(clampColumns(columns + 1))}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
clampColumns,
|
||||
nextColSpan,
|
||||
sortSlots,
|
||||
reorderSlots,
|
||||
addSlot,
|
||||
removeSlot,
|
||||
setColSpan,
|
||||
setHeight,
|
||||
heightToRowSpan,
|
||||
MIN_TILE_HEIGHT,
|
||||
} from "./layout-utils";
|
||||
import type { GridSlot } from "./types";
|
||||
|
||||
function slot(id: string, order: number, colSpan: 1 | 2 | 3 = 1): GridSlot {
|
||||
return { id, order, colSpan, height: null };
|
||||
}
|
||||
|
||||
describe("clampColumns", () => {
|
||||
it("clamps into 1..4 and rounds", () => {
|
||||
expect(clampColumns(0)).toBe(1);
|
||||
expect(clampColumns(1)).toBe(1);
|
||||
expect(clampColumns(4)).toBe(4);
|
||||
expect(clampColumns(9)).toBe(4);
|
||||
expect(clampColumns(2.6)).toBe(3);
|
||||
expect(clampColumns(NaN)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextColSpan", () => {
|
||||
it("cycles 1->2->3->1 when 3+ columns", () => {
|
||||
expect(nextColSpan(1, 3)).toBe(2);
|
||||
expect(nextColSpan(2, 3)).toBe(3);
|
||||
expect(nextColSpan(3, 3)).toBe(1);
|
||||
});
|
||||
it("caps at the column count", () => {
|
||||
expect(nextColSpan(1, 2)).toBe(2);
|
||||
expect(nextColSpan(2, 2)).toBe(1);
|
||||
expect(nextColSpan(1, 1)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortSlots", () => {
|
||||
it("returns a stable copy ordered by order", () => {
|
||||
const input = [slot("b", 2), slot("a", 1), slot("c", 3)];
|
||||
const out = sortSlots(input);
|
||||
expect(out.map((s) => s.id)).toEqual(["a", "b", "c"]);
|
||||
expect(out).not.toBe(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reorderSlots", () => {
|
||||
const base = [slot("a", 0), slot("b", 1), slot("c", 2)];
|
||||
|
||||
it("moves a slot to the front and renumbers densely", () => {
|
||||
const out = reorderSlots(base, "c", 0);
|
||||
expect(out.map((s) => s.id)).toEqual(["c", "a", "b"]);
|
||||
expect(out.map((s) => s.order)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it("moves a slot to the end", () => {
|
||||
const out = reorderSlots(base, "a", 99);
|
||||
expect(out.map((s) => s.id)).toEqual(["b", "c", "a"]);
|
||||
expect(out.map((s) => s.order)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it("is a no-op-ish renumber for an unknown id", () => {
|
||||
const out = reorderSlots(base, "zzz", 0);
|
||||
expect(out.map((s) => s.id)).toEqual(["a", "b", "c"]);
|
||||
expect(out.map((s) => s.order)).toEqual([0, 1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addSlot", () => {
|
||||
it("appends with clamped colSpan and next order", () => {
|
||||
const out = addSlot(
|
||||
[slot("a", 0)],
|
||||
{ id: "b", label: "B", defaultColSpan: 3 },
|
||||
2,
|
||||
);
|
||||
expect(out).toHaveLength(2);
|
||||
const added = out.find((s) => s.id === "b")!;
|
||||
expect(added.order).toBe(1);
|
||||
expect(added.colSpan).toBe(2); // clamped to columns
|
||||
});
|
||||
it("does not duplicate an existing id", () => {
|
||||
const input = [slot("a", 0)];
|
||||
expect(addSlot(input, { id: "a", label: "A" }, 3)).toBe(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeSlot", () => {
|
||||
it("removes and renumbers", () => {
|
||||
const out = removeSlot([slot("a", 0), slot("b", 1), slot("c", 2)], "b");
|
||||
expect(out.map((s) => s.id)).toEqual(["a", "c"]);
|
||||
expect(out.map((s) => s.order)).toEqual([0, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setColSpan / setHeight", () => {
|
||||
it("sets colSpan for the matching id only", () => {
|
||||
const out = setColSpan([slot("a", 0, 1), slot("b", 1, 1)], "a", 2);
|
||||
expect(out.find((s) => s.id === "a")!.colSpan).toBe(2);
|
||||
expect(out.find((s) => s.id === "b")!.colSpan).toBe(1);
|
||||
});
|
||||
it("enforces a minimum tile height", () => {
|
||||
const out = setHeight([slot("a", 0)], "a", 10);
|
||||
expect(out[0].height).toBe(MIN_TILE_HEIGHT);
|
||||
const out2 = setHeight([slot("a", 0)], "a", 300);
|
||||
expect(out2[0].height).toBe(300);
|
||||
});
|
||||
});
|
||||
|
||||
describe("heightToRowSpan", () => {
|
||||
const unit = 8;
|
||||
const gap = 12;
|
||||
it("returns at least one row for tiny tiles", () => {
|
||||
expect(heightToRowSpan(0, unit, gap)).toBe(1);
|
||||
expect(heightToRowSpan(5, unit, gap)).toBe(1);
|
||||
});
|
||||
it("grows the row span with height (taller -> more rows)", () => {
|
||||
const short = heightToRowSpan(200, unit, gap);
|
||||
const tall = heightToRowSpan(400, unit, gap);
|
||||
expect(tall).toBeGreaterThan(short);
|
||||
});
|
||||
it("rounds up so content is never clipped", () => {
|
||||
// 200px content: ceil((200 + 12) / (8 + 12)) = ceil(10.6) = 11
|
||||
expect(heightToRowSpan(200, unit, gap)).toBe(11);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { GridSlot, GridColSpan, GridCardCatalogEntry } from "./types";
|
||||
|
||||
export const MIN_TILE_HEIGHT = 80;
|
||||
export const MAX_COLUMNS = 4;
|
||||
export const MIN_COLUMNS = 1;
|
||||
|
||||
export function clampColumns(n: number): number {
|
||||
if (!Number.isFinite(n)) return MIN_COLUMNS;
|
||||
return Math.min(MAX_COLUMNS, Math.max(MIN_COLUMNS, Math.round(n)));
|
||||
}
|
||||
|
||||
/** Cycle a tile width 1 -> 2 -> 3 -> 1, capped at the available column count. */
|
||||
export function nextColSpan(
|
||||
current: GridColSpan,
|
||||
columns: number,
|
||||
): GridColSpan {
|
||||
const max = Math.min(3, Math.max(1, columns)) as GridColSpan;
|
||||
return (((current % max) + 1) as GridColSpan) || 1;
|
||||
}
|
||||
|
||||
/** Sort slots by order ascending (stable copy). */
|
||||
export function sortSlots(slots: GridSlot[]): GridSlot[] {
|
||||
return [...slots].sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the dragged slot so it lands at `targetOrder`, then renumber every slot's
|
||||
* order to a dense 0..n-1 sequence (the masonry placement is derived from order).
|
||||
*/
|
||||
export function reorderSlots(
|
||||
slots: GridSlot[],
|
||||
draggedId: string,
|
||||
targetOrder: number,
|
||||
): GridSlot[] {
|
||||
const moved = slots.find((s) => s.id === draggedId);
|
||||
if (!moved) return sortSlots(slots).map((s, i) => ({ ...s, order: i }));
|
||||
const without = sortSlots(slots).filter((s) => s.id !== draggedId);
|
||||
const insertIdx = without.findIndex((s) => s.order >= targetOrder);
|
||||
const at = insertIdx === -1 ? without.length : insertIdx;
|
||||
return [...without.slice(0, at), moved, ...without.slice(at)].map((s, i) => ({
|
||||
...s,
|
||||
order: i,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Append a catalog card as a new slot, clamping its width to the column count. */
|
||||
export function addSlot(
|
||||
slots: GridSlot[],
|
||||
entry: GridCardCatalogEntry,
|
||||
columns: number,
|
||||
): GridSlot[] {
|
||||
if (slots.some((s) => s.id === entry.id)) return slots;
|
||||
const maxOrder = slots.reduce((m, s) => Math.max(m, s.order), -1);
|
||||
const colSpan = Math.min(
|
||||
entry.defaultColSpan ?? 1,
|
||||
Math.min(3, Math.max(1, columns)),
|
||||
) as GridColSpan;
|
||||
return [
|
||||
...slots,
|
||||
{
|
||||
id: entry.id,
|
||||
order: maxOrder + 1,
|
||||
colSpan,
|
||||
height: entry.defaultHeight ?? null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function removeSlot(slots: GridSlot[], id: string): GridSlot[] {
|
||||
return slots
|
||||
.filter((s) => s.id !== id)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((s, i) => ({ ...s, order: i }));
|
||||
}
|
||||
|
||||
export function setColSpan(
|
||||
slots: GridSlot[],
|
||||
id: string,
|
||||
colSpan: GridColSpan,
|
||||
): GridSlot[] {
|
||||
return slots.map((s) => (s.id === id ? { ...s, colSpan } : s));
|
||||
}
|
||||
|
||||
export function setHeight(
|
||||
slots: GridSlot[],
|
||||
id: string,
|
||||
height: number,
|
||||
): GridSlot[] {
|
||||
const h = Math.max(MIN_TILE_HEIGHT, Math.round(height));
|
||||
return slots.map((s) => (s.id === id ? { ...s, height: h } : s));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a tile pixel height into the number of grid rows it should span for
|
||||
* the masonry (row-span) layout. The grid uses `rowUnit`px rows separated by
|
||||
* `rowGap`px, so a tile of height H occupies ceil((H + gap) / (unit + gap))
|
||||
* rows. Kept pure for unit testing the packing math.
|
||||
*/
|
||||
export function heightToRowSpan(
|
||||
height: number,
|
||||
rowUnit: number,
|
||||
rowGap: number,
|
||||
): number {
|
||||
return Math.max(1, Math.ceil((height + rowGap) / (rowUnit + rowGap)));
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Shared types for the CardGridCanvas: a content-agnostic, N-column masonry of
|
||||
* movable, variable-width, resizable tiles. The dashboard and Host Metrics both
|
||||
* describe their layout with these primitives.
|
||||
*/
|
||||
|
||||
export type GridColSpan = 1 | 2 | 3;
|
||||
|
||||
export interface GridSlot {
|
||||
/** Stable card identifier (the consumer's card id). */
|
||||
id: string;
|
||||
/** Global order across the masonry; column placement is derived from order. */
|
||||
order: number;
|
||||
/** Tile width in grid columns (the "tetris" piece width). */
|
||||
colSpan: GridColSpan;
|
||||
/** Fixed pixel height, or null for content/auto height. */
|
||||
height: number | null;
|
||||
}
|
||||
|
||||
export interface GridLayout {
|
||||
slots: GridSlot[];
|
||||
/** Number of masonry columns (1..4). */
|
||||
columns: number;
|
||||
}
|
||||
|
||||
/** Catalog entry describing an addable card, used by the edit-mode Add tray. */
|
||||
export interface GridCardCatalogEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
defaultColSpan?: GridColSpan;
|
||||
defaultHeight?: number | null;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface BarSeriesItem {
|
||||
label: string;
|
||||
/** 0..max value driving the bar width. */
|
||||
value: number;
|
||||
/** Optional right-aligned value text (defaults to value). */
|
||||
valueLabel?: string;
|
||||
}
|
||||
|
||||
export interface BarSeriesProps {
|
||||
items: BarSeriesItem[];
|
||||
/** Domain max; defaults to the largest value (min 1). */
|
||||
max?: number;
|
||||
className?: string;
|
||||
colorClassName?: string;
|
||||
}
|
||||
|
||||
/** Compact horizontal bar list with a label, a track, and a value. */
|
||||
export function BarSeries({
|
||||
items,
|
||||
max,
|
||||
className,
|
||||
colorClassName = "bg-accent-brand",
|
||||
}: BarSeriesProps) {
|
||||
const domainMax = max ?? Math.max(1, ...items.map((i) => i.value));
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-2", className)}>
|
||||
{items.map((item, i) => {
|
||||
const pct = Math.min(100, Math.max(0, (item.value / domainMax) * 100));
|
||||
return (
|
||||
<div key={`${item.label}-${i}`} className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-[11px] font-semibold">
|
||||
{item.valueLabel ?? item.value}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full overflow-hidden bg-muted">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full transition-all duration-500",
|
||||
colorClassName,
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { gaugeArc } from "./geometry";
|
||||
|
||||
export interface RadialGaugeProps {
|
||||
/** 0..100 percentage, or null for "no data". */
|
||||
value: number | null;
|
||||
/** Large centered text (defaults to the percentage). */
|
||||
label?: string;
|
||||
/** Small caption under the value. */
|
||||
caption?: string;
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
className?: string;
|
||||
/** Tailwind text-color class for the value arc. */
|
||||
colorClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A 270-degree radial gauge with a big centered readout. Auto-themes via
|
||||
* currentColor; the track uses the muted token.
|
||||
*/
|
||||
export function RadialGauge({
|
||||
value,
|
||||
label,
|
||||
caption,
|
||||
size = 120,
|
||||
strokeWidth = 9,
|
||||
className,
|
||||
colorClassName = "text-accent-brand",
|
||||
}: RadialGaugeProps) {
|
||||
const pct = value === null ? 0 : Math.min(100, Math.max(0, value));
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
|
||||
// 270-degree sweep starting at the bottom-left (135deg), going clockwise.
|
||||
const startAngle = 135;
|
||||
const { trackLen, valueLen, circumference } = gaugeArc(value, radius, 270);
|
||||
|
||||
const display = label ?? (value === null ? "N/A" : `${Math.round(pct)}%`);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative shrink-0", className)}
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
className={colorClassName}
|
||||
style={{ transform: `rotate(${startAngle}deg)` }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={radius}
|
||||
fill="none"
|
||||
strokeWidth={strokeWidth}
|
||||
className="stroke-muted"
|
||||
strokeDasharray={`${trackLen} ${circumference}`}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={`${valueLen} ${circumference}`}
|
||||
strokeLinecap="round"
|
||||
className="transition-[stroke-dasharray] duration-500"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span
|
||||
className={cn(
|
||||
"text-xl font-bold leading-none md:text-2xl",
|
||||
value === null ? "text-muted-foreground" : colorClassName,
|
||||
)}
|
||||
>
|
||||
{display}
|
||||
</span>
|
||||
{caption && (
|
||||
<span className="mt-1 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{caption}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useId } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { sparklineGeometry, SPARKLINE_VIEW_W } from "./geometry";
|
||||
|
||||
export interface SparklineProps {
|
||||
/** Series of values, oldest first. */
|
||||
data: Array<number | null | undefined>;
|
||||
/**
|
||||
* Fixed value domain [min, max]. When omitted the domain is derived from the
|
||||
* data. Providing a stable domain (e.g. [0, 100] for percentages) avoids the
|
||||
* jittery rescaling the old sparkline had.
|
||||
*/
|
||||
domain?: [number, number];
|
||||
className?: string;
|
||||
/** Tailwind text-color class for the line/fill (uses currentColor). */
|
||||
colorClassName?: string;
|
||||
/** Show a dot on the last point. */
|
||||
showLastDot?: boolean;
|
||||
/** Internal coordinate height; the SVG scales to its container. */
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact area + line chart. Uses a non-scaling stroke + a stable value domain
|
||||
* so the line stays crisp and readable regardless of container size.
|
||||
*/
|
||||
export function Sparkline({
|
||||
data,
|
||||
domain,
|
||||
className,
|
||||
colorClassName = "text-accent-brand",
|
||||
showLastDot = true,
|
||||
height = 56,
|
||||
}: SparklineProps) {
|
||||
const gradientId = useId();
|
||||
const { hasData, coords, linePath, areaPath } = sparklineGeometry(
|
||||
data,
|
||||
height,
|
||||
domain,
|
||||
);
|
||||
const last = coords[coords.length - 1];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full overflow-hidden border border-border/50 bg-muted/20",
|
||||
colorClassName,
|
||||
className,
|
||||
)}
|
||||
style={{ height }}
|
||||
>
|
||||
{hasData && (
|
||||
<svg
|
||||
className="absolute inset-0 h-full w-full"
|
||||
viewBox={`0 0 ${SPARKLINE_VIEW_W} ${height}`}
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="currentColor" stopOpacity="0.28" />
|
||||
<stop offset="100%" stopColor="currentColor" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={areaPath} fill={`url(#${gradientId})`} />
|
||||
<path
|
||||
d={linePath}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
className="opacity-80"
|
||||
/>
|
||||
{showLastDot && last && (
|
||||
<circle
|
||||
cx={last[0]}
|
||||
cy={last[1]}
|
||||
r={2.5}
|
||||
fill="currentColor"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Dense key/value row used across metric and manager cards. */
|
||||
export function StatRow({
|
||||
label,
|
||||
value,
|
||||
mono = false,
|
||||
className,
|
||||
}: {
|
||||
label: ReactNode;
|
||||
value: ReactNode;
|
||||
mono?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-3 py-1.5 text-xs",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="shrink-0 text-muted-foreground">{label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate text-right font-semibold",
|
||||
mono && "font-mono",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A compact stacked stat: small uppercase caption over a bold value. */
|
||||
export function MiniStat({
|
||||
caption,
|
||||
value,
|
||||
valueClassName,
|
||||
className,
|
||||
}: {
|
||||
caption: ReactNode;
|
||||
value: ReactNode;
|
||||
valueClassName?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-0.5", className)}>
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{caption}
|
||||
</span>
|
||||
<span className={cn("text-sm font-bold leading-none", valueClassName)}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { sparklineGeometry, gaugeArc, SPARKLINE_VIEW_W } from "./geometry";
|
||||
|
||||
describe("sparklineGeometry", () => {
|
||||
it("returns no data for fewer than 2 points", () => {
|
||||
expect(sparklineGeometry([], 50).hasData).toBe(false);
|
||||
expect(sparklineGeometry([5], 50).hasData).toBe(false);
|
||||
});
|
||||
|
||||
it("maps endpoints to the full width", () => {
|
||||
const g = sparklineGeometry([0, 100], 100, [0, 100]);
|
||||
expect(g.coords[0][0]).toBe(0);
|
||||
expect(g.coords[1][0]).toBe(SPARKLINE_VIEW_W);
|
||||
});
|
||||
|
||||
it("inverts the y-axis (higher value = smaller y)", () => {
|
||||
const g = sparklineGeometry([0, 100], 100, [0, 100]);
|
||||
expect(g.coords[0][1]).toBe(100); // value 0 -> bottom
|
||||
expect(g.coords[1][1]).toBe(0); // value 100 -> top
|
||||
});
|
||||
|
||||
it("respects a fixed domain (no rescaling)", () => {
|
||||
// With domain [0,100], a mid value of 50 sits at half height regardless
|
||||
// of the other points.
|
||||
const g = sparklineGeometry([50, 50, 50], 100, [0, 100]);
|
||||
for (const [, y] of g.coords) expect(y).toBe(50);
|
||||
});
|
||||
|
||||
it("clamps out-of-domain values", () => {
|
||||
const g = sparklineGeometry([-20, 200], 100, [0, 100]);
|
||||
expect(g.coords[0][1]).toBe(100); // clamped to lo
|
||||
expect(g.coords[1][1]).toBe(0); // clamped to hi
|
||||
});
|
||||
|
||||
it("treats null/NaN as 0", () => {
|
||||
const g = sparklineGeometry([null, undefined, NaN], 100, [0, 100]);
|
||||
expect(g.hasData).toBe(true);
|
||||
for (const [, y] of g.coords) expect(y).toBe(100);
|
||||
});
|
||||
|
||||
it("builds line and area paths", () => {
|
||||
const g = sparklineGeometry([0, 100], 100, [0, 100]);
|
||||
expect(g.linePath.startsWith("M ")).toBe(true);
|
||||
expect(g.areaPath.startsWith("M 0,100")).toBe(true);
|
||||
expect(g.areaPath.endsWith("Z")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gaugeArc", () => {
|
||||
it("zero value yields zero value-arc", () => {
|
||||
const a = gaugeArc(0, 50);
|
||||
expect(a.valueLen).toBe(0);
|
||||
expect(a.trackLen).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("null is treated as 0", () => {
|
||||
expect(gaugeArc(null, 50).valueLen).toBe(0);
|
||||
});
|
||||
|
||||
it("100% fills the whole track", () => {
|
||||
const a = gaugeArc(100, 50);
|
||||
expect(a.valueLen).toBeCloseTo(a.trackLen, 6);
|
||||
});
|
||||
|
||||
it("track is the swept fraction of the circumference", () => {
|
||||
const a = gaugeArc(50, 50, 270);
|
||||
expect(a.trackLen).toBeCloseTo(a.circumference * 0.75, 6);
|
||||
expect(a.valueLen).toBeCloseTo(a.trackLen * 0.5, 6);
|
||||
});
|
||||
|
||||
it("clamps values above 100", () => {
|
||||
const a = gaugeArc(150, 50);
|
||||
expect(a.valueLen).toBeCloseTo(a.trackLen, 6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/** Pure geometry helpers for the chart primitives (unit-tested). */
|
||||
|
||||
export const SPARKLINE_VIEW_W = 300;
|
||||
|
||||
export interface SparklineGeometry {
|
||||
hasData: boolean;
|
||||
coords: Array<readonly [number, number]>;
|
||||
linePath: string;
|
||||
areaPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a value series to SVG coordinates within [0..VIEW_W] x [0..height].
|
||||
* A fixed `domain` keeps the y-axis stable (no per-tick rescaling).
|
||||
*/
|
||||
export function sparklineGeometry(
|
||||
data: Array<number | null | undefined>,
|
||||
height: number,
|
||||
domain?: [number, number],
|
||||
): SparklineGeometry {
|
||||
const points = data.map((v) =>
|
||||
typeof v === "number" && isFinite(v) ? v : 0,
|
||||
);
|
||||
const hasData = points.length >= 2;
|
||||
if (!hasData)
|
||||
return { hasData: false, coords: [], linePath: "", areaPath: "" };
|
||||
|
||||
const lo = domain ? domain[0] : Math.min(...points);
|
||||
const hiRaw = domain ? domain[1] : Math.max(...points);
|
||||
const hi = hiRaw === lo ? lo + 1 : hiRaw;
|
||||
const range = hi - lo;
|
||||
|
||||
const coords = points.map((v, i) => {
|
||||
const x = (i / (points.length - 1)) * SPARKLINE_VIEW_W;
|
||||
const clamped = Math.min(hi, Math.max(lo, v));
|
||||
const y = height - ((clamped - lo) / range) * height;
|
||||
return [x, y] as const;
|
||||
});
|
||||
|
||||
const line = coords.map(([x, y]) => `${x},${y}`).join(" ");
|
||||
return {
|
||||
hasData: true,
|
||||
coords,
|
||||
linePath: `M ${line}`,
|
||||
areaPath: `M 0,${height} L ${line} L ${SPARKLINE_VIEW_W},${height} Z`,
|
||||
};
|
||||
}
|
||||
|
||||
export interface GaugeArc {
|
||||
trackLen: number;
|
||||
valueLen: number;
|
||||
circumference: number;
|
||||
}
|
||||
|
||||
/** Arc lengths for a radial gauge sweeping `sweepDeg` degrees. */
|
||||
export function gaugeArc(
|
||||
value: number | null,
|
||||
radius: number,
|
||||
sweepDeg = 270,
|
||||
): GaugeArc {
|
||||
const pct = value === null ? 0 : Math.min(100, Math.max(0, value));
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const trackLen = circumference * (sweepDeg / 360);
|
||||
const valueLen = trackLen * (pct / 100);
|
||||
return { trackLen, valueLen, circumference };
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { Sparkline, type SparklineProps } from "./Sparkline";
|
||||
export { RadialGauge, type RadialGaugeProps } from "./RadialGauge";
|
||||
export {
|
||||
BarSeries,
|
||||
type BarSeriesItem,
|
||||
type BarSeriesProps,
|
||||
} from "./BarSeries";
|
||||
export { StatRow, MiniStat } from "./StatRow";
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Folder,
|
||||
FolderGit2,
|
||||
FolderOpen,
|
||||
Server,
|
||||
ServerCog,
|
||||
Cloud,
|
||||
CloudCog,
|
||||
Database,
|
||||
HardDrive,
|
||||
Box,
|
||||
Boxes,
|
||||
Container,
|
||||
Network,
|
||||
Router,
|
||||
Wifi,
|
||||
Globe,
|
||||
Globe2,
|
||||
Terminal,
|
||||
Cpu,
|
||||
MemoryStick,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
Lock,
|
||||
Key,
|
||||
Rocket,
|
||||
FlaskConical,
|
||||
Wrench,
|
||||
Hammer,
|
||||
Gauge,
|
||||
Activity,
|
||||
Layers,
|
||||
Building,
|
||||
Building2,
|
||||
Home,
|
||||
Briefcase,
|
||||
Code,
|
||||
GitBranch,
|
||||
Bug,
|
||||
Settings,
|
||||
Cog,
|
||||
Star,
|
||||
Flag,
|
||||
Bookmark,
|
||||
Tag,
|
||||
Users,
|
||||
User,
|
||||
Zap,
|
||||
Flame,
|
||||
Leaf,
|
||||
Mail,
|
||||
Monitor,
|
||||
Smartphone,
|
||||
Search,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { Input } from "@/components/input";
|
||||
|
||||
export const FOLDER_ICON_MAP: Record<string, LucideIcon> = {
|
||||
folder: Folder,
|
||||
"folder-open": FolderOpen,
|
||||
"folder-git": FolderGit2,
|
||||
server: Server,
|
||||
"server-cog": ServerCog,
|
||||
cloud: Cloud,
|
||||
"cloud-cog": CloudCog,
|
||||
database: Database,
|
||||
"hard-drive": HardDrive,
|
||||
box: Box,
|
||||
boxes: Boxes,
|
||||
container: Container,
|
||||
network: Network,
|
||||
router: Router,
|
||||
wifi: Wifi,
|
||||
globe: Globe,
|
||||
"globe-2": Globe2,
|
||||
terminal: Terminal,
|
||||
cpu: Cpu,
|
||||
memory: MemoryStick,
|
||||
shield: Shield,
|
||||
"shield-check": ShieldCheck,
|
||||
lock: Lock,
|
||||
key: Key,
|
||||
rocket: Rocket,
|
||||
flask: FlaskConical,
|
||||
wrench: Wrench,
|
||||
hammer: Hammer,
|
||||
gauge: Gauge,
|
||||
activity: Activity,
|
||||
layers: Layers,
|
||||
building: Building,
|
||||
"building-2": Building2,
|
||||
home: Home,
|
||||
briefcase: Briefcase,
|
||||
code: Code,
|
||||
"git-branch": GitBranch,
|
||||
bug: Bug,
|
||||
settings: Settings,
|
||||
cog: Cog,
|
||||
star: Star,
|
||||
flag: Flag,
|
||||
bookmark: Bookmark,
|
||||
tag: Tag,
|
||||
users: Users,
|
||||
user: User,
|
||||
zap: Zap,
|
||||
flame: Flame,
|
||||
leaf: Leaf,
|
||||
mail: Mail,
|
||||
monitor: Monitor,
|
||||
smartphone: Smartphone,
|
||||
};
|
||||
|
||||
export const FOLDER_ICON_IDS = Object.keys(FOLDER_ICON_MAP);
|
||||
|
||||
export const DEFAULT_FOLDER_ICON = "folder";
|
||||
export const DEFAULT_FOLDER_COLOR = "#f59145";
|
||||
|
||||
export const SUGGESTED_FOLDER_COLORS = [
|
||||
"#f59145",
|
||||
"#ef4444",
|
||||
"#eab308",
|
||||
"#22c55e",
|
||||
"#14b8a6",
|
||||
"#3b82f6",
|
||||
"#6366f1",
|
||||
"#a855f7",
|
||||
"#ec4899",
|
||||
"#6b7280",
|
||||
];
|
||||
|
||||
export function FolderIconEl({
|
||||
icon,
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
icon?: string | null;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
const Icon = FOLDER_ICON_MAP[icon ?? ""] ?? Folder;
|
||||
return <Icon className={className} style={style} />;
|
||||
}
|
||||
|
||||
const HEX_RE = /^#([0-9a-fA-F]{6})$/;
|
||||
|
||||
export function isValidHex(value: string): boolean {
|
||||
return HEX_RE.test(value.trim());
|
||||
}
|
||||
|
||||
export function HexColorPicker({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (hex: string) => void;
|
||||
}) {
|
||||
const safe = isValidHex(value) ? value : DEFAULT_FOLDER_COLOR;
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={safe}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="h-8 w-10 shrink-0 cursor-pointer rounded-none border border-input bg-transparent p-0.5"
|
||||
/>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="#6b7280"
|
||||
className="font-mono uppercase"
|
||||
maxLength={7}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{SUGGESTED_FOLDER_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => onChange(c)}
|
||||
className={`size-6 rounded-none border transition-all ${
|
||||
value.toLowerCase() === c.toLowerCase()
|
||||
? "ring-2 ring-offset-1 ring-offset-background ring-white/60"
|
||||
: "opacity-80 hover:opacity-100"
|
||||
}`}
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconPicker({
|
||||
value,
|
||||
color,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
color?: string;
|
||||
onChange: (icon: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState("");
|
||||
const filtered = query.trim()
|
||||
? FOLDER_ICON_IDS.filter((id) =>
|
||||
id.toLowerCase().includes(query.trim().toLowerCase()),
|
||||
)
|
||||
: FOLDER_ICON_IDS;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2 px-2.5 h-8 bg-muted/60 border border-border/60 rounded-none">
|
||||
<Search className="size-3 text-muted-foreground/60 shrink-0" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("hosts.iconSearchPlaceholder")}
|
||||
className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50 text-foreground min-w-0"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-8 gap-1.5 max-h-40 overflow-y-auto pr-1">
|
||||
{filtered.map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => onChange(id)}
|
||||
title={id}
|
||||
className={`flex items-center justify-center h-9 border transition-colors ${
|
||||
value === id
|
||||
? "border-accent-brand/50 bg-accent-brand/10"
|
||||
: "border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<FolderIconEl
|
||||
icon={id}
|
||||
className="size-4"
|
||||
style={value === id ? { color } : undefined}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Server, RefreshCw, CheckSquare, Square, Download } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/select";
|
||||
import {
|
||||
discoverProxmoxGuests,
|
||||
bulkImportSSHHosts,
|
||||
getSSHHosts,
|
||||
} from "@/main-axios";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import type { ProxmoxGuest } from "@/types/proxmox";
|
||||
|
||||
interface ProxmoxDiscoverDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
hosts: SSHHostWithStatus[];
|
||||
onHostsChanged: (hosts: SSHHostWithStatus[]) => void;
|
||||
/** Pre-select a specific host and skip the host picker */
|
||||
preselectedHostId?: number;
|
||||
/** Credential to use for imported hosts */
|
||||
defaultCredentialId?: number | null;
|
||||
/** Username from the default credential */
|
||||
defaultUsername?: string;
|
||||
}
|
||||
|
||||
export function ProxmoxDiscoverDialog({
|
||||
open,
|
||||
onClose,
|
||||
hosts,
|
||||
onHostsChanged,
|
||||
preselectedHostId,
|
||||
defaultCredentialId,
|
||||
defaultUsername,
|
||||
}: ProxmoxDiscoverDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedHostId, setSelectedHostId] = useState<string>(
|
||||
preselectedHostId ? String(preselectedHostId) : "",
|
||||
);
|
||||
const [discovering, setDiscovering] = useState(false);
|
||||
const [guests, setGuests] = useState<ProxmoxGuest[] | null>(null);
|
||||
const [discoveredCredentialId, setDiscoveredCredentialId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [importing, setImporting] = useState(false);
|
||||
|
||||
// When opened from the dropdown (no preselectedHostId), only show Proxmox-enabled hosts
|
||||
const sshHosts = hosts.filter(
|
||||
(h) => !("isFolder" in h) && h.enableProxmox === true,
|
||||
);
|
||||
|
||||
// The Proxmox host the discovery runs against — imported guests are grouped
|
||||
// into a folder named after it. Use the same id resolution as discovery so
|
||||
// it also works when launched directly from a host action (preselectedHostId).
|
||||
const effectiveHostId =
|
||||
preselectedHostId != null ? String(preselectedHostId) : selectedHostId;
|
||||
const sourceHost = hosts.find(
|
||||
(h) => !("isFolder" in h) && String(h.id) === effectiveHostId,
|
||||
);
|
||||
const importFolder = sourceHost?.name || "Proxmox";
|
||||
|
||||
function reset() {
|
||||
if (!preselectedHostId) setSelectedHostId("");
|
||||
setGuests(null);
|
||||
setDiscoveredCredentialId(null);
|
||||
setSelected(new Set());
|
||||
setDiscovering(false);
|
||||
setImporting(false);
|
||||
}
|
||||
|
||||
async function handleDiscover() {
|
||||
const hostId =
|
||||
preselectedHostId ?? (selectedHostId ? Number(selectedHostId) : null);
|
||||
if (!hostId) return;
|
||||
setDiscovering(true);
|
||||
setGuests(null);
|
||||
setDiscoveredCredentialId(null);
|
||||
setSelected(new Set());
|
||||
try {
|
||||
const result = await discoverProxmoxGuests(hostId);
|
||||
setGuests(result.guests);
|
||||
setDiscoveredCredentialId(result.credentialId ?? null);
|
||||
setSelected(
|
||||
new Set(
|
||||
result.guests
|
||||
.filter((g) => g.status === "running")
|
||||
.map((g) => g.vmid),
|
||||
),
|
||||
);
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message ?? t("hosts.proxmoxDiscoveryFailed"));
|
||||
} finally {
|
||||
setDiscovering(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!guests || selected.size === 0) return;
|
||||
setImporting(true);
|
||||
try {
|
||||
// Prefer explicitly configured credential, then fall back to the host's own credential
|
||||
const credId = defaultCredentialId ?? discoveredCredentialId;
|
||||
|
||||
const toImport = guests
|
||||
.filter((g) => selected.has(g.vmid))
|
||||
.map((g) => ({
|
||||
name: g.name,
|
||||
ip: g.ip ?? "0.0.0.0",
|
||||
port: g.connectionType === "rdp" ? 3389 : 22,
|
||||
username: defaultUsername ?? "root",
|
||||
folder: importFolder,
|
||||
...(credId != null
|
||||
? {
|
||||
authType: "credential" as const,
|
||||
credentialId: credId,
|
||||
overrideCredentialUsername: true,
|
||||
}
|
||||
: { authType: "password" as const }),
|
||||
enableTerminal: g.connectionType !== "rdp",
|
||||
enableFileManager: g.connectionType !== "rdp",
|
||||
enableTunnel: g.connectionType !== "rdp",
|
||||
enableSsh: g.connectionType !== "rdp",
|
||||
enableRdp: g.connectionType === "rdp",
|
||||
enableDocker: g.enableDocker,
|
||||
connectionType: g.connectionType,
|
||||
tags: ["proxmox", g.type, g.node],
|
||||
}));
|
||||
|
||||
const result = await bulkImportSSHHosts(toImport, false);
|
||||
const updated = await getSSHHosts();
|
||||
onHostsChanged(updated);
|
||||
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
|
||||
|
||||
const msg = [
|
||||
result.success
|
||||
? t("hosts.proxmoxResultImported", { count: result.success })
|
||||
: null,
|
||||
result.updated
|
||||
? t("hosts.proxmoxResultUpdated", { count: result.updated })
|
||||
: null,
|
||||
result.failed
|
||||
? t("hosts.proxmoxResultFailed", { count: result.failed })
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
toast.success(t("hosts.proxmoxImportComplete", { summary: msg }));
|
||||
|
||||
if (result.failed === 0) {
|
||||
reset();
|
||||
onClose();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message ?? t("hosts.proxmoxImportFailed"));
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const nodeGroups = guests
|
||||
? guests.reduce(
|
||||
(acc, g) => {
|
||||
if (!acc[g.node]) acc[g.node] = [];
|
||||
acc[g.node].push(g);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, ProxmoxGuest[]>,
|
||||
)
|
||||
: {};
|
||||
|
||||
const canDiscover = preselectedHostId != null || !!selectedHostId;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
if (!v) {
|
||||
reset();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Server className="size-4" />
|
||||
{t("hosts.proxmoxImportTitle")}
|
||||
</DialogTitle>
|
||||
<a
|
||||
href="https://docs.termix.site/features/files-and-hosts/proxmox-import"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[10px] text-accent-brand hover:underline"
|
||||
>
|
||||
{t("hosts.docsLink")}
|
||||
</a>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Host selector — hidden when launched from a specific host */}
|
||||
{!preselectedHostId && (
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={selectedHostId}
|
||||
onValueChange={setSelectedHostId}
|
||||
disabled={discovering}
|
||||
>
|
||||
<SelectTrigger className="flex-1 text-xs h-8">
|
||||
<SelectValue placeholder={t("hosts.proxmoxSelectHost")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sshHosts.map((h) => (
|
||||
<SelectItem
|
||||
key={h.id}
|
||||
value={String(h.id)}
|
||||
className="text-xs"
|
||||
>
|
||||
{h.name || h.ip}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!canDiscover || discovering}
|
||||
onClick={handleDiscover}
|
||||
className="shrink-0"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-3.5 mr-1.5 ${discovering ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{t("hosts.proxmoxDiscover")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* When launched from a specific host, show Discover directly */}
|
||||
{preselectedHostId && !guests && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={discovering}
|
||||
onClick={handleDiscover}
|
||||
className="w-full"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-3.5 mr-1.5 ${discovering ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{discovering
|
||||
? t("hosts.proxmoxDiscovering")
|
||||
: t("hosts.proxmoxDiscoverGuests")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Guest list */}
|
||||
{guests !== null && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t("hosts.proxmoxGuestsSelected", {
|
||||
count: guests.length,
|
||||
selected: selected.size,
|
||||
})}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="hover:text-foreground transition-colors"
|
||||
onClick={() =>
|
||||
setSelected(new Set(guests.map((g) => g.vmid)))
|
||||
}
|
||||
>
|
||||
{t("hosts.proxmoxSelectAll")}
|
||||
</button>
|
||||
<span>·</span>
|
||||
<button
|
||||
className="hover:text-foreground transition-colors"
|
||||
onClick={() => setSelected(new Set())}
|
||||
>
|
||||
{t("hosts.proxmoxDeselectAll")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{guests.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground py-4 text-center">
|
||||
{t("hosts.proxmoxNoGuests")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="max-h-64 overflow-y-auto rounded-md border border-border divide-y divide-border">
|
||||
{Object.entries(nodeGroups).map(([node, nodeGuests]) => (
|
||||
<div key={node}>
|
||||
<div className="px-2 py-1 text-[10px] font-medium text-muted-foreground bg-muted/50 uppercase tracking-wider">
|
||||
{node}
|
||||
</div>
|
||||
{nodeGuests.map((g) => (
|
||||
<button
|
||||
key={g.vmid}
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 text-xs hover:bg-accent transition-colors text-left"
|
||||
onClick={() => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(g.vmid)) next.delete(g.vmid);
|
||||
else next.add(g.vmid);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{selected.has(g.vmid) ? (
|
||||
<CheckSquare className="size-3.5 text-primary shrink-0" />
|
||||
) : (
|
||||
<Square className="size-3.5 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 truncate">{g.name}</span>
|
||||
<span className="text-muted-foreground text-[10px] shrink-0">
|
||||
{g.type.toUpperCase()} {g.vmid}
|
||||
</span>
|
||||
<span
|
||||
className={`shrink-0 text-[10px] ${g.status === "running" ? "text-green-400" : "text-muted-foreground"}`}
|
||||
>
|
||||
{g.status}
|
||||
</span>
|
||||
{g.ip && (
|
||||
<span className="text-muted-foreground text-[10px] shrink-0 font-mono">
|
||||
{g.ip}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
reset();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
{guests !== null && selected.size > 0 && (
|
||||
<Button size="sm" disabled={importing} onClick={handleImport}>
|
||||
{importing ? (
|
||||
<RefreshCw className="size-3.5 animate-spin mr-1.5" />
|
||||
) : (
|
||||
<Download className="size-3.5 mr-1.5" />
|
||||
)}
|
||||
{t("hosts.proxmoxImportButton", { count: selected.size })}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export function SettingRow({
|
||||
}: {
|
||||
label: string;
|
||||
badge?: string;
|
||||
description?: string;
|
||||
description?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
|
||||
@@ -1,887 +0,0 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import React, { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { Auth } from "@/auth/LoginPage.tsx";
|
||||
import { AlertManager } from "@/dashboard/panels/alerts/AlertManager.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import {
|
||||
getUserInfo,
|
||||
getDatabaseHealth,
|
||||
getUptime,
|
||||
getVersionInfo,
|
||||
getSSHHosts,
|
||||
getCredentials,
|
||||
getRecentActivity,
|
||||
resetRecentActivity,
|
||||
getAllServerStatuses,
|
||||
getServerMetricsById,
|
||||
registerMetricsViewer,
|
||||
sendMetricsHeartbeat,
|
||||
getGuacamoleDpi,
|
||||
getGuacamoleTokenFromHost,
|
||||
isCurrentAuthInvalidationError,
|
||||
type RecentActivityItem,
|
||||
} from "@/main-axios.ts";
|
||||
import { useSidebar } from "@/components/sidebar.tsx";
|
||||
import { Separator } from "@/components/separator.tsx";
|
||||
import { useTabs } from "@/shell/TabContext.tsx";
|
||||
import { Kbd } from "@/components/kbd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Settings as SettingsIcon } from "lucide-react";
|
||||
import { ServerOverviewCard } from "@/dashboard/cards/ServerOverviewCard";
|
||||
import { RecentActivityCard } from "@/dashboard/cards/RecentActivityCard";
|
||||
import { QuickActionsCard } from "@/dashboard/cards/QuickActionsCard";
|
||||
import { ServerStatsCard } from "@/dashboard/cards/ServerStatsCard";
|
||||
import { NetworkGraphCard } from "@/dashboard/cards/NetworkGraphCard";
|
||||
import { useDashboardPreferences } from "@/dashboard/hooks/useDashboardPreferences";
|
||||
import { DashboardSettingsDialog } from "@/dashboard/components/DashboardSettingsDialog";
|
||||
import { SimpleLoader } from "@/lib/SimpleLoader";
|
||||
|
||||
interface DashboardProps {
|
||||
onSelectView: (view: string) => void;
|
||||
isAuthenticated: boolean;
|
||||
authLoading: boolean;
|
||||
onAuthSuccess: (authData: {
|
||||
isAdmin: boolean;
|
||||
username: string | null;
|
||||
userId: string | null;
|
||||
}) => void;
|
||||
isTopbarOpen: boolean;
|
||||
rightSidebarOpen?: boolean;
|
||||
rightSidebarWidth?: number;
|
||||
initialDbError?: string | null;
|
||||
}
|
||||
|
||||
export function Dashboard({
|
||||
isAuthenticated,
|
||||
authLoading,
|
||||
onAuthSuccess,
|
||||
isTopbarOpen,
|
||||
rightSidebarOpen = false,
|
||||
rightSidebarWidth = 400,
|
||||
initialDbError = null,
|
||||
}: DashboardProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [loggedIn, setLoggedIn] = useState(isAuthenticated);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [, setUsername] = useState<string | null>(null);
|
||||
const [userId, setUserId] = useState<string | null>(null);
|
||||
const [, setDbError] = useState<string | null>(initialDbError);
|
||||
|
||||
const [uptime, setUptime] = useState<string>("0d 0h 0m");
|
||||
const [versionStatus, setVersionStatus] = useState<
|
||||
"up_to_date" | "requires_update" | "beta"
|
||||
>("up_to_date");
|
||||
const [versionText, setVersionText] = useState<string>("");
|
||||
const [dbHealth, setDbHealth] = useState<"healthy" | "error">("healthy");
|
||||
const [totalServers, setTotalServers] = useState<number>(0);
|
||||
const [totalTunnels, setTotalTunnels] = useState<number>(0);
|
||||
const [totalCredentials, setTotalCredentials] = useState<number>(0);
|
||||
const [updateCheckDisabled, setUpdateCheckDisabled] = useState<boolean>(
|
||||
localStorage.getItem("disableUpdateCheck") === "true",
|
||||
);
|
||||
const [recentActivity, setRecentActivity] = useState<RecentActivityItem[]>(
|
||||
[],
|
||||
);
|
||||
const [recentActivityLoading, setRecentActivityLoading] =
|
||||
useState<boolean>(true);
|
||||
const [serverStats, setServerStats] = useState<
|
||||
Array<{ id: number; name: string; cpu: number | null; ram: number | null }>
|
||||
>([]);
|
||||
const [serverStatsLoading, setServerStatsLoading] = useState<boolean>(true);
|
||||
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
||||
const [viewerSessions, setViewerSessions] = useState<Map<number, string>>(
|
||||
new Map(),
|
||||
);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
|
||||
const { addTab, setCurrentTab, tabs: tabList } = useTabs();
|
||||
const {
|
||||
layout,
|
||||
loading: preferencesLoading,
|
||||
updateLayout,
|
||||
resetLayout,
|
||||
} = useDashboardPreferences(loggedIn);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mainWidthPct = layout?.mainWidthPct ?? 68;
|
||||
|
||||
const handleDividerMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startPct = mainWidthPct;
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
if (!containerRef.current) return;
|
||||
const totalW = containerRef.current.getBoundingClientRect().width;
|
||||
const delta = ev.clientX - startX;
|
||||
const newPct = Math.min(
|
||||
85,
|
||||
Math.max(30, startPct + (delta / totalW) * 100),
|
||||
);
|
||||
if (layout) updateLayout({ ...layout, mainWidthPct: newPct });
|
||||
};
|
||||
const onUp = () => {
|
||||
window.removeEventListener("mousemove", onMove);
|
||||
window.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
window.addEventListener("mousemove", onMove);
|
||||
window.addEventListener("mouseup", onUp);
|
||||
},
|
||||
[mainWidthPct, layout, updateLayout],
|
||||
);
|
||||
|
||||
const handleCardHeightMouseDown = useCallback(
|
||||
(e: React.MouseEvent, cardId: string, currentHeight: number) => {
|
||||
e.preventDefault();
|
||||
const startY = e.clientY;
|
||||
const startH = currentHeight;
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
const newH = Math.max(180, startH + (ev.clientY - startY));
|
||||
if (!layout) return;
|
||||
updateLayout({
|
||||
...layout,
|
||||
cards: layout.cards.map((c) =>
|
||||
c.id === cardId ? { ...c, height: Math.round(newH) } : c,
|
||||
),
|
||||
});
|
||||
};
|
||||
const onUp = () => {
|
||||
window.removeEventListener("mousemove", onMove);
|
||||
window.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
window.addEventListener("mousemove", onMove);
|
||||
window.addEventListener("mouseup", onUp);
|
||||
},
|
||||
[layout, updateLayout],
|
||||
);
|
||||
|
||||
let sidebarState: "expanded" | "collapsed" = "expanded";
|
||||
try {
|
||||
const sidebar = useSidebar();
|
||||
sidebarState = sidebar.state;
|
||||
} catch {
|
||||
// Sidebar context is not available on every dashboard mount path.
|
||||
}
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
|
||||
const rightMarginPx = 17;
|
||||
const bottomMarginPx = 8;
|
||||
|
||||
useEffect(() => {
|
||||
setLoggedIn(isAuthenticated);
|
||||
}, [isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
getUserInfo()
|
||||
.then((meRes) => {
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
setUsername(meRes.username || null);
|
||||
setUserId(meRes.userId || null);
|
||||
setDbError(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (isCurrentAuthInvalidationError(err)) {
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
setUserId(null);
|
||||
console.warn("Session expired - please log in again");
|
||||
setDbError("Session expired - please log in again");
|
||||
} else {
|
||||
setDbError(null);
|
||||
}
|
||||
});
|
||||
|
||||
getDatabaseHealth()
|
||||
.then(() => {
|
||||
setDbError(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err?.response?.data?.error?.includes("Database")) {
|
||||
setDbError(
|
||||
"Could not connect to the database. Please try again later.",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loggedIn) return;
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
const uptimeInfo = await getUptime();
|
||||
setUptime(uptimeInfo.formatted);
|
||||
|
||||
const updateDisabled =
|
||||
localStorage.getItem("disableUpdateCheck") === "true";
|
||||
setUpdateCheckDisabled(updateDisabled);
|
||||
if (!updateDisabled) {
|
||||
const versionInfo = await getVersionInfo();
|
||||
setVersionText(`v${versionInfo.localVersion}`);
|
||||
if (
|
||||
versionInfo.status === "up_to_date" ||
|
||||
versionInfo.status === "requires_update" ||
|
||||
versionInfo.status === "beta"
|
||||
) {
|
||||
setVersionStatus(versionInfo.status);
|
||||
}
|
||||
} else {
|
||||
const versionInfo = await getVersionInfo();
|
||||
setVersionText(`v${versionInfo.localVersion}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await getDatabaseHealth();
|
||||
setDbHealth("healthy");
|
||||
} catch {
|
||||
setDbHealth("error");
|
||||
}
|
||||
|
||||
const hostsResponse = await getSSHHosts();
|
||||
const hosts = Array.isArray(hostsResponse) ? hostsResponse : [];
|
||||
setTotalServers(hosts.length);
|
||||
|
||||
let totalTunnelsCount = 0;
|
||||
for (const host of hosts) {
|
||||
if (host.tunnelConnections) {
|
||||
try {
|
||||
const tunnelConnections = Array.isArray(host.tunnelConnections)
|
||||
? host.tunnelConnections
|
||||
: JSON.parse(host.tunnelConnections);
|
||||
if (Array.isArray(tunnelConnections)) {
|
||||
totalTunnelsCount += tunnelConnections.length;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Dashboard operation failed:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
setTotalTunnels(totalTunnelsCount);
|
||||
|
||||
const credentialsResponse = await getCredentials();
|
||||
const credentials = Array.isArray(credentialsResponse)
|
||||
? credentialsResponse
|
||||
: [];
|
||||
setTotalCredentials(credentials.length);
|
||||
|
||||
setRecentActivityLoading(true);
|
||||
const activityResponse = await getRecentActivity(35);
|
||||
const activity = Array.isArray(activityResponse)
|
||||
? activityResponse
|
||||
: [];
|
||||
setRecentActivity(activity);
|
||||
setRecentActivityLoading(false);
|
||||
|
||||
setServerStatsLoading(true);
|
||||
|
||||
// Fetch current host statuses once so we can skip offline hosts
|
||||
// before issuing per-host register-viewer / metrics requests.
|
||||
let hostStatuses: Record<number, { status?: string }> = {};
|
||||
try {
|
||||
hostStatuses = (await getAllServerStatuses()) as Record<
|
||||
number,
|
||||
{ status?: string }
|
||||
>;
|
||||
} catch {
|
||||
// Best-effort: if the status endpoint is unavailable, fall back
|
||||
// to the previous behavior and still attempt each host.
|
||||
hostStatuses = {};
|
||||
}
|
||||
|
||||
const newViewerSessions = new Map<number, string>();
|
||||
const serversWithStats = await Promise.all(
|
||||
hosts.slice(0, 50).map(
|
||||
async (host: {
|
||||
id: number;
|
||||
name: string;
|
||||
authType?: string;
|
||||
statsConfig?:
|
||||
| string
|
||||
| {
|
||||
metricsEnabled?: boolean;
|
||||
statusCheckEnabled?: boolean;
|
||||
};
|
||||
}) => {
|
||||
try {
|
||||
let statsConfig: {
|
||||
metricsEnabled?: boolean;
|
||||
statusCheckEnabled?: boolean;
|
||||
} = {
|
||||
metricsEnabled: true,
|
||||
statusCheckEnabled: true,
|
||||
};
|
||||
if (host.statsConfig) {
|
||||
if (typeof host.statsConfig === "string") {
|
||||
statsConfig = JSON.parse(host.statsConfig);
|
||||
} else {
|
||||
statsConfig = host.statsConfig;
|
||||
}
|
||||
}
|
||||
|
||||
if (statsConfig.metricsEnabled === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (host.authType === "none") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (host.authType === "opkssh") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Skip hosts that are known to be offline: no metrics can
|
||||
// possibly exist for them, and hitting /metrics/:id would
|
||||
// just 404. If the status is unknown (e.g. no entry yet
|
||||
// or statusCheckEnabled === false) we still attempt.
|
||||
if (statsConfig.statusCheckEnabled !== false) {
|
||||
const knownStatus = hostStatuses?.[host.id]?.status;
|
||||
if (knownStatus === "offline") {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const existingSession = viewerSessions.get(host.id);
|
||||
let sessionId = existingSession;
|
||||
let registrationSkipped = false;
|
||||
|
||||
if (!existingSession) {
|
||||
try {
|
||||
const viewerResult = await registerMetricsViewer(host.id);
|
||||
if (viewerResult.skipped) {
|
||||
// Metrics disabled/unsupported on this host; don't
|
||||
// poll and don't surface this as an error.
|
||||
registrationSkipped = true;
|
||||
} else if (
|
||||
viewerResult.success &&
|
||||
viewerResult.viewerSessionId
|
||||
) {
|
||||
sessionId = viewerResult.viewerSessionId;
|
||||
newViewerSessions.set(host.id, sessionId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to register viewer for host ${host.id}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
newViewerSessions.set(host.id, existingSession);
|
||||
}
|
||||
|
||||
if (registrationSkipped) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metrics = await getServerMetricsById(host.id);
|
||||
if (!metrics) {
|
||||
return {
|
||||
id: host.id,
|
||||
name: host.name || `Host ${host.id}`,
|
||||
cpu: null,
|
||||
ram: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: host.id,
|
||||
name: host.name || `Host ${host.id}`,
|
||||
cpu: metrics.cpu?.percent ?? null,
|
||||
ram: metrics.memory?.percent ?? null,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
id: host.id,
|
||||
name: host.name || `Host ${host.id}`,
|
||||
cpu: null,
|
||||
ram: null,
|
||||
};
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
setViewerSessions(newViewerSessions);
|
||||
const validServerStats = serversWithStats.filter(
|
||||
(
|
||||
server,
|
||||
): server is {
|
||||
id: number;
|
||||
name: string;
|
||||
cpu: number | null;
|
||||
ram: number | null;
|
||||
} => server !== null,
|
||||
);
|
||||
setServerStats(validServerStats);
|
||||
setServerStatsLoading(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch dashboard data:", error);
|
||||
setRecentActivityLoading(false);
|
||||
setServerStatsLoading(false);
|
||||
} finally {
|
||||
setInitialLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDashboardData();
|
||||
|
||||
const interval = setInterval(fetchDashboardData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loggedIn]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loggedIn || viewerSessions.size === 0) return;
|
||||
|
||||
const heartbeatInterval = setInterval(async () => {
|
||||
for (const [, sessionId] of viewerSessions) {
|
||||
try {
|
||||
await sendMetricsHeartbeat(sessionId);
|
||||
} catch (error) {
|
||||
console.error("Failed to send heartbeat:", error);
|
||||
}
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
return () => clearInterval(heartbeatInterval);
|
||||
}, [loggedIn, viewerSessions]);
|
||||
|
||||
const handleResetActivity = async () => {
|
||||
try {
|
||||
await resetRecentActivity();
|
||||
setRecentActivity([]);
|
||||
} catch (error) {
|
||||
console.error("Failed to reset activity:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivityClick = (item: RecentActivityItem) => {
|
||||
getSSHHosts().then((hosts) => {
|
||||
const host = hosts.find((h: { id: number }) => h.id === item.hostId);
|
||||
if (!host) return;
|
||||
|
||||
if (item.type === "terminal") {
|
||||
addTab({
|
||||
type: "terminal",
|
||||
title: item.hostName,
|
||||
hostConfig: host,
|
||||
});
|
||||
} else if (item.type === "file_manager") {
|
||||
addTab({
|
||||
type: "file_manager",
|
||||
title: item.hostName,
|
||||
hostConfig: host,
|
||||
});
|
||||
} else if (item.type === "server_stats") {
|
||||
addTab({
|
||||
type: "server_stats",
|
||||
title: item.hostName,
|
||||
hostConfig: host,
|
||||
});
|
||||
} else if (item.type === "tunnel") {
|
||||
addTab({
|
||||
type: "tunnel",
|
||||
title: item.hostName,
|
||||
hostConfig: host,
|
||||
});
|
||||
} else if (item.type === "docker") {
|
||||
addTab({
|
||||
type: "docker",
|
||||
title: item.hostName,
|
||||
hostConfig: host,
|
||||
});
|
||||
} else if (item.type === "telnet") {
|
||||
getGuacamoleTokenFromHost(host.id)
|
||||
.then((result) => {
|
||||
addTab({
|
||||
type: "telnet",
|
||||
title: item.hostName,
|
||||
hostConfig: host,
|
||||
connectionConfig: {
|
||||
token: result.token,
|
||||
protocol: "telnet",
|
||||
type: "telnet",
|
||||
hostname: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
password: host.password,
|
||||
domain: host.domain,
|
||||
security: host.security,
|
||||
"ignore-cert": host.ignoreCert,
|
||||
dpi: getGuacamoleDpi(host),
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to get telnet token:", error);
|
||||
});
|
||||
} else if (item.type === "vnc") {
|
||||
getGuacamoleTokenFromHost(host.id)
|
||||
.then((result) => {
|
||||
addTab({
|
||||
type: "vnc",
|
||||
title: item.hostName,
|
||||
hostConfig: host,
|
||||
connectionConfig: {
|
||||
token: result.token,
|
||||
protocol: "vnc",
|
||||
type: "vnc",
|
||||
hostname: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
password: host.password,
|
||||
domain: host.domain,
|
||||
security: host.security,
|
||||
"ignore-cert": host.ignoreCert,
|
||||
dpi: getGuacamoleDpi(host),
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to get vnc token:", error);
|
||||
});
|
||||
} else if (item.type === "rdp") {
|
||||
getGuacamoleTokenFromHost(host.id)
|
||||
.then((result) => {
|
||||
addTab({
|
||||
type: "rdp",
|
||||
title: item.hostName,
|
||||
hostConfig: host,
|
||||
connectionConfig: {
|
||||
token: result.token,
|
||||
protocol: "rdp",
|
||||
type: "rdp",
|
||||
hostname: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
password: host.password,
|
||||
domain: host.domain,
|
||||
security: host.security,
|
||||
"ignore-cert": host.ignoreCert,
|
||||
dpi: getGuacamoleDpi(host),
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to get rdp token:", error);
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleServerStatClick = (serverId: number, serverName: string) => {
|
||||
getSSHHosts().then((hosts) => {
|
||||
const host = hosts.find((h: { id: number }) => h.id === serverId);
|
||||
if (!host) return;
|
||||
|
||||
addTab({
|
||||
type: "server_stats",
|
||||
title: serverName,
|
||||
hostConfig: host,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddHost = () => {
|
||||
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
|
||||
if (sshManagerTab) {
|
||||
setCurrentTab(sshManagerTab.id);
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new CustomEvent("host-manager:add-host"));
|
||||
}, 100);
|
||||
} else {
|
||||
const id = addTab({
|
||||
type: "ssh_manager",
|
||||
title: "Host Manager",
|
||||
initialTab: "hosts",
|
||||
});
|
||||
setCurrentTab(id);
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new CustomEvent("host-manager:add-host"));
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddCredential = () => {
|
||||
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
|
||||
if (sshManagerTab) {
|
||||
setCurrentTab(sshManagerTab.id);
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new CustomEvent("host-manager:add-credential"));
|
||||
}, 100);
|
||||
} else {
|
||||
const id = addTab({
|
||||
type: "ssh_manager",
|
||||
title: "Host Manager",
|
||||
initialTab: "credentials",
|
||||
});
|
||||
setCurrentTab(id);
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new CustomEvent("host-manager:add-credential"));
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenAdminSettings = () => {
|
||||
const adminTab = tabList.find((t) => t.type === "admin");
|
||||
if (adminTab) {
|
||||
setCurrentTab(adminTab.id);
|
||||
} else {
|
||||
const id = addTab({ type: "admin", title: "Admin Settings" });
|
||||
setCurrentTab(id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenUserProfile = () => {
|
||||
const userProfileTab = tabList.find((t) => t.type === "user_profile");
|
||||
if (userProfileTab) {
|
||||
setCurrentTab(userProfileTab.id);
|
||||
} else {
|
||||
const id = addTab({ type: "user_profile", title: "User Profile" });
|
||||
setCurrentTab(id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{!loggedIn ? (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Auth
|
||||
setLoggedIn={setLoggedIn}
|
||||
setIsAdmin={setIsAdmin}
|
||||
setUsername={setUsername}
|
||||
setUserId={setUserId}
|
||||
loggedIn={loggedIn}
|
||||
authLoading={authLoading}
|
||||
setDbError={setDbError}
|
||||
onAuthSuccess={onAuthSuccess}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="bg-canvas text-foreground rounded-lg border-2 border-edge overflow-hidden flex min-w-0"
|
||||
style={{
|
||||
marginLeft: leftMarginPx,
|
||||
marginRight: rightSidebarOpen
|
||||
? `calc(var(--right-sidebar-width, ${rightSidebarWidth}px) + 8px)`
|
||||
: rightMarginPx,
|
||||
marginTop: topMarginPx,
|
||||
marginBottom: bottomMarginPx,
|
||||
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
|
||||
transition:
|
||||
"margin-left 200ms linear, margin-right 200ms linear, margin-top 200ms linear",
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col relative z-10 w-full h-full min-w-0">
|
||||
<SimpleLoader
|
||||
visible={initialLoading}
|
||||
message={t("dashboard.loading")}
|
||||
/>
|
||||
<div className="flex flex-row items-center justify-between w-full px-3 mt-3 min-w-0 flex-wrap gap-2">
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
<div className="text-2xl text-foreground font-semibold shrink-0">
|
||||
{t("dashboard.title")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row gap-3 flex-wrap min-w-0">
|
||||
<div className="flex flex-col items-center gap-4 justify-center mr-5 min-w-0 shrink">
|
||||
<p className="text-muted-foreground text-sm whitespace-nowrap">
|
||||
Press <Kbd>L Shift</Kbd> twice to open the command palette
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="font-semibold shrink-0 !bg-canvas"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
"https://github.com/Termix-SSH/Termix",
|
||||
"_blank",
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("dashboard.github")}
|
||||
</Button>
|
||||
<Button
|
||||
className="font-semibold shrink-0 !bg-canvas"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
"https://github.com/Termix-SSH/Support/issues/new",
|
||||
"_blank",
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("dashboard.support")}
|
||||
</Button>
|
||||
<Button
|
||||
className="font-semibold shrink-0 !bg-canvas"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
"https://discord.com/invite/jVQGdvHDrf",
|
||||
"_blank",
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("dashboard.discord")}
|
||||
</Button>
|
||||
<Button
|
||||
className="font-semibold shrink-0 !bg-canvas"
|
||||
variant="outline"
|
||||
onClick={() => setSettingsDialogOpen(true)}
|
||||
>
|
||||
<SettingsIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="mt-3 p-0.25" />
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex flex-row flex-1 my-5 mx-5 gap-0 min-h-0 min-w-0 overflow-hidden"
|
||||
>
|
||||
{!preferencesLoading &&
|
||||
layout &&
|
||||
(() => {
|
||||
const enabledCards = layout.cards
|
||||
.filter((card) => card.enabled)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
|
||||
const mainCards = enabledCards.filter(
|
||||
(c) => (c.panel ?? "main") === "main",
|
||||
);
|
||||
const sideCards = enabledCards.filter(
|
||||
(c) => c.panel === "side",
|
||||
);
|
||||
|
||||
const renderCard = (card: (typeof enabledCards)[0]) => {
|
||||
if (card.id === "server_overview") {
|
||||
return (
|
||||
<ServerOverviewCard
|
||||
key={card.id}
|
||||
loggedIn={loggedIn}
|
||||
versionText={versionText}
|
||||
versionStatus={versionStatus}
|
||||
uptime={uptime}
|
||||
dbHealth={dbHealth}
|
||||
totalServers={totalServers}
|
||||
totalTunnels={totalTunnels}
|
||||
totalCredentials={totalCredentials}
|
||||
updateCheckDisabled={updateCheckDisabled}
|
||||
/>
|
||||
);
|
||||
} else if (card.id === "recent_activity") {
|
||||
return (
|
||||
<RecentActivityCard
|
||||
key={card.id}
|
||||
activities={recentActivity}
|
||||
loading={recentActivityLoading}
|
||||
onReset={handleResetActivity}
|
||||
onActivityClick={handleActivityClick}
|
||||
/>
|
||||
);
|
||||
} else if (card.id === "network_graph") {
|
||||
return (
|
||||
<NetworkGraphCard
|
||||
key={card.id}
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
rightSidebarOpen={rightSidebarOpen}
|
||||
rightSidebarWidth={rightSidebarWidth}
|
||||
/>
|
||||
);
|
||||
} else if (card.id === "quick_actions") {
|
||||
return (
|
||||
<QuickActionsCard
|
||||
key={card.id}
|
||||
isAdmin={isAdmin}
|
||||
onAddHost={handleAddHost}
|
||||
onAddCredential={handleAddCredential}
|
||||
onOpenAdminSettings={handleOpenAdminSettings}
|
||||
onOpenUserProfile={handleOpenUserProfile}
|
||||
/>
|
||||
);
|
||||
} else if (card.id === "server_stats") {
|
||||
return (
|
||||
<ServerStatsCard
|
||||
key={card.id}
|
||||
serverStats={serverStats}
|
||||
loading={serverStatsLoading}
|
||||
onServerClick={handleServerStatClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderCardWithHandle = (
|
||||
card: (typeof enabledCards)[0],
|
||||
) => {
|
||||
const currentHeight = card.height ?? 280;
|
||||
return (
|
||||
<div
|
||||
key={card.id}
|
||||
className="flex flex-col"
|
||||
style={
|
||||
card.height
|
||||
? { height: card.height, flexShrink: 0 }
|
||||
: { flex: 1, minHeight: 280 }
|
||||
}
|
||||
>
|
||||
<div className="flex-1 min-h-0">{renderCard(card)}</div>
|
||||
<div
|
||||
className="flex-shrink-0 h-2 my-0.5 flex items-center justify-center cursor-row-resize group"
|
||||
onMouseDown={(e) =>
|
||||
handleCardHeightMouseDown(e, card.id, currentHeight)
|
||||
}
|
||||
>
|
||||
<div className="w-16 h-0.5 rounded-full bg-border group-hover:bg-primary/50 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="flex flex-col gap-2 overflow-auto min-h-0"
|
||||
style={{ width: `${mainWidthPct}%`, minWidth: 0 }}
|
||||
>
|
||||
{mainCards.map((card) => renderCardWithHandle(card))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex-shrink-0 w-2 mx-1.5 flex items-center justify-center cursor-col-resize group"
|
||||
onMouseDown={handleDividerMouseDown}
|
||||
>
|
||||
<div className="w-0.5 h-16 rounded-full bg-border group-hover:bg-primary/50 transition-colors" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex flex-col gap-2 overflow-auto min-h-0"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
>
|
||||
{sideCards.map((card) => renderCardWithHandle(card))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlertManager userId={userId} loggedIn={loggedIn} />
|
||||
|
||||
{layout && (
|
||||
<DashboardSettingsDialog
|
||||
open={settingsDialogOpen}
|
||||
onOpenChange={setSettingsDialogOpen}
|
||||
currentLayout={layout}
|
||||
onSave={updateLayout}
|
||||
onReset={resetLayout}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -403,7 +403,7 @@ function HostStatusCard({
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => onOpenTab(host, "stats")}
|
||||
onClick={() => onOpenTab(host, "host-metrics")}
|
||||
className="flex items-center justify-between px-4 py-2.5 border-b border-border last:border-0 hover:bg-muted/50 cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
@@ -508,7 +508,7 @@ function RecentActivityCard({
|
||||
const typeToTab: Record<RecentActivityItem["type"], TabType> = {
|
||||
terminal: "terminal",
|
||||
file_manager: "files",
|
||||
server_stats: "stats",
|
||||
server_stats: "host-metrics",
|
||||
tunnel: "tunnel",
|
||||
docker: "docker",
|
||||
rdp: "rdp",
|
||||
@@ -1120,7 +1120,11 @@ export function DashboardTab({
|
||||
getCredentials()
|
||||
.then((res) =>
|
||||
setCredentialCount(
|
||||
Array.isArray(res?.credentials) ? res.credentials.length : 0,
|
||||
Array.isArray(res)
|
||||
? res.length
|
||||
: Array.isArray(res?.credentials)
|
||||
? res.credentials.length
|
||||
: 0,
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
@@ -1354,6 +1358,20 @@ export function DashboardTab({
|
||||
{t("dashboard.discord")}
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href="https://docs.termix.site/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{t("dashboard.docs")}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
{allSlots.map((slot) => (
|
||||
@@ -1475,6 +1493,20 @@ export function DashboardTab({
|
||||
{t("dashboard.discord")}
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href="https://docs.termix.site/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{t("dashboard.docs")}
|
||||
</a>
|
||||
</Button>
|
||||
<Separator orientation="vertical" className="mx-1 h-5" />
|
||||
{editMode ? (
|
||||
<>
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
RotateCw,
|
||||
AlertCircle,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Upload,
|
||||
Link2,
|
||||
FolderPlus,
|
||||
@@ -806,11 +807,11 @@ export function NetworkGraphCard({
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleConnectAction("stats")}
|
||||
onClick={() => handleConnectAction("host-metrics")}
|
||||
className="flex items-center gap-2 px-3 py-2 text-xs w-full text-left hover:bg-muted transition-colors"
|
||||
>
|
||||
<Server className="size-3 shrink-0" />
|
||||
{t("networkGraph.serverStats")}
|
||||
{t("networkGraph.hostMetrics")}
|
||||
</button>
|
||||
{!embedded && (
|
||||
<>
|
||||
@@ -1303,6 +1304,16 @@ export function NetworkGraphCard({
|
||||
<Upload className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
<a
|
||||
href="https://docs.termix.site/features/dashboard/network-graph"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={t("hosts.docsLink")}
|
||||
className={btnCls}
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
{cytoscapeEl}
|
||||
{dialogs}
|
||||
@@ -1355,6 +1366,15 @@ export function NetworkGraphCard({
|
||||
>
|
||||
<ArrowUp className="size-3" />
|
||||
</button>
|
||||
<a
|
||||
href="https://docs.termix.site/features/dashboard/network-graph"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={t("hosts.docsLink")}
|
||||
className={btnCls}
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{cytoscapeEl}
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FastForward, Server, Key, Settings, User } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
|
||||
interface QuickActionsCardProps {
|
||||
isAdmin: boolean;
|
||||
onAddHost: () => void;
|
||||
onAddCredential: () => void;
|
||||
onOpenAdminSettings: () => void;
|
||||
onOpenUserProfile: () => void;
|
||||
}
|
||||
|
||||
export function QuickActionsCard({
|
||||
isAdmin,
|
||||
onAddHost,
|
||||
onAddCredential,
|
||||
onOpenAdminSettings,
|
||||
onOpenUserProfile,
|
||||
}: QuickActionsCardProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="border-2 border-edge rounded-md flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20 !bg-elevated">
|
||||
<div className="flex flex-col mx-3 my-2 overflow-y-auto overflow-x-hidden thin-scrollbar">
|
||||
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
|
||||
<FastForward className="mr-3" />
|
||||
{t("dashboard.quickActions")}
|
||||
</p>
|
||||
<div className="grid gap-4 grid-cols-3 auto-rows-min overflow-y-auto overflow-x-hidden thin-scrollbar">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 !bg-canvas"
|
||||
onClick={onAddHost}
|
||||
>
|
||||
<div className="flex flex-col items-center w-full max-w-full">
|
||||
<Server
|
||||
className="shrink-0"
|
||||
style={{ width: "40px", height: "40px" }}
|
||||
/>
|
||||
<span
|
||||
className="font-semibold text-sm mt-2 text-center block"
|
||||
style={{
|
||||
wordWrap: "break-word",
|
||||
overflowWrap: "break-word",
|
||||
width: "100%",
|
||||
maxWidth: "100%",
|
||||
hyphens: "auto",
|
||||
display: "block",
|
||||
whiteSpace: "normal",
|
||||
}}
|
||||
>
|
||||
{t("dashboard.addHost")}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 !bg-canvas"
|
||||
onClick={onAddCredential}
|
||||
>
|
||||
<div className="flex flex-col items-center w-full max-w-full">
|
||||
<Key
|
||||
className="shrink-0"
|
||||
style={{ width: "40px", height: "40px" }}
|
||||
/>
|
||||
<span
|
||||
className="font-semibold text-sm mt-2 text-center block"
|
||||
style={{
|
||||
wordWrap: "break-word",
|
||||
overflowWrap: "break-word",
|
||||
width: "100%",
|
||||
maxWidth: "100%",
|
||||
hyphens: "auto",
|
||||
display: "block",
|
||||
whiteSpace: "normal",
|
||||
}}
|
||||
>
|
||||
{t("dashboard.addCredential")}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 !bg-canvas"
|
||||
onClick={onOpenAdminSettings}
|
||||
>
|
||||
<div className="flex flex-col items-center w-full max-w-full">
|
||||
<Settings
|
||||
className="shrink-0"
|
||||
style={{ width: "40px", height: "40px" }}
|
||||
/>
|
||||
<span
|
||||
className="font-semibold text-sm mt-2 text-center block"
|
||||
style={{
|
||||
wordWrap: "break-word",
|
||||
overflowWrap: "break-word",
|
||||
width: "100%",
|
||||
maxWidth: "100%",
|
||||
hyphens: "auto",
|
||||
display: "block",
|
||||
whiteSpace: "normal",
|
||||
}}
|
||||
>
|
||||
{t("dashboard.adminSettings")}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 !bg-canvas"
|
||||
onClick={onOpenUserProfile}
|
||||
>
|
||||
<div className="flex flex-col items-center w-full max-w-full">
|
||||
<User
|
||||
className="shrink-0"
|
||||
style={{ width: "40px", height: "40px" }}
|
||||
/>
|
||||
<span
|
||||
className="font-semibold text-sm mt-2 text-center block"
|
||||
style={{
|
||||
wordWrap: "break-word",
|
||||
overflowWrap: "break-word",
|
||||
width: "100%",
|
||||
maxWidth: "100%",
|
||||
hyphens: "auto",
|
||||
display: "block",
|
||||
whiteSpace: "normal",
|
||||
}}
|
||||
>
|
||||
{t("dashboard.userProfile")}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Clock,
|
||||
Loader2,
|
||||
Terminal,
|
||||
FolderOpen,
|
||||
Server,
|
||||
ArrowDownUp,
|
||||
Container,
|
||||
Monitor,
|
||||
Eye,
|
||||
MessagesSquare,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { type RecentActivityItem } from "@/main-axios";
|
||||
|
||||
interface RecentActivityCardProps {
|
||||
activities: RecentActivityItem[];
|
||||
loading: boolean;
|
||||
onReset: () => void;
|
||||
onActivityClick: (item: RecentActivityItem) => void;
|
||||
}
|
||||
|
||||
function formatRelativeTime(
|
||||
timestamp: string,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
const diffMs = Date.now() - new Date(timestamp).getTime();
|
||||
if (diffMs < 0) return t("dashboard.justNow");
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
if (diffSec < 60) return t("dashboard.justNow");
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
if (diffMin < 60) return `${diffMin}m`;
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
if (diffHour < 24) return `${diffHour}h`;
|
||||
return `${Math.floor(diffHour / 24)}d`;
|
||||
}
|
||||
|
||||
export function RecentActivityCard({
|
||||
activities,
|
||||
loading,
|
||||
onReset,
|
||||
onActivityClick,
|
||||
}: RecentActivityCardProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="border-2 border-edge rounded-md flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20 !bg-elevated">
|
||||
<div className="flex flex-col mx-3 my-2 flex-1 overflow-hidden">
|
||||
<div className="flex flex-row items-center justify-between mb-3 mt-1">
|
||||
<p className="text-xl font-semibold flex flex-row items-center">
|
||||
<Clock className="mr-3" />
|
||||
{t("dashboard.recentActivity")}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-2 !border-edge h-7 !bg-canvas"
|
||||
onClick={onReset}
|
||||
>
|
||||
{t("dashboard.reset")}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className={`grid gap-4 grid-cols-3 auto-rows-min overflow-x-hidden thin-scrollbar ${loading ? "overflow-y-hidden" : "overflow-y-auto"}`}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex flex-row items-center text-muted-foreground text-sm animate-pulse">
|
||||
<Loader2 className="animate-spin mr-2" size={16} />
|
||||
<span>{t("dashboard.loadingRecentActivity")}</span>
|
||||
</div>
|
||||
) : activities.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("dashboard.noRecentActivity")}
|
||||
</p>
|
||||
) : (
|
||||
activities
|
||||
.filter((item, index, array) => {
|
||||
if (index === 0) return true;
|
||||
|
||||
const prevItem = array[index - 1];
|
||||
return !(
|
||||
item.hostId === prevItem.hostId && item.type === prevItem.type
|
||||
);
|
||||
})
|
||||
.map((item) => (
|
||||
<Button
|
||||
key={item.id}
|
||||
variant="outline"
|
||||
className="border-2 !border-edge min-w-0 !bg-canvas"
|
||||
onClick={() => onActivityClick(item)}
|
||||
>
|
||||
{item.type === "terminal" ? (
|
||||
<Terminal size={20} className="shrink-0" />
|
||||
) : item.type === "file_manager" ? (
|
||||
<FolderOpen size={20} className="shrink-0" />
|
||||
) : item.type === "server_stats" ? (
|
||||
<Server size={20} className="shrink-0" />
|
||||
) : item.type === "tunnel" ? (
|
||||
<ArrowDownUp size={20} className="shrink-0" />
|
||||
) : item.type === "docker" ? (
|
||||
<Container size={20} className="shrink-0" />
|
||||
) : item.type === "telnet" ? (
|
||||
<MessagesSquare size={20} className="shrink-0" />
|
||||
) : item.type === "vnc" ? (
|
||||
<Eye size={20} className="shrink-0" />
|
||||
) : item.type === "rdp" ? (
|
||||
<Monitor size={20} className="shrink-0" />
|
||||
) : (
|
||||
<Terminal size={20} className="shrink-0" />
|
||||
)}
|
||||
<div className="flex flex-col items-start min-w-0 ml-2">
|
||||
<p className="truncate font-semibold leading-none">
|
||||
{item.hostName}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 leading-none">
|
||||
{formatRelativeTime(item.timestamp, t)}
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Server,
|
||||
History,
|
||||
Clock,
|
||||
Database,
|
||||
Key,
|
||||
ArrowDownUp,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { UpdateLog } from "@/dashboard/panels/UpdateLog";
|
||||
|
||||
interface ServerOverviewCardProps {
|
||||
loggedIn: boolean;
|
||||
versionText: string;
|
||||
versionStatus: "up_to_date" | "requires_update" | "beta";
|
||||
uptime: string;
|
||||
dbHealth: "healthy" | "error";
|
||||
totalServers: number;
|
||||
totalTunnels: number;
|
||||
totalCredentials: number;
|
||||
updateCheckDisabled?: boolean;
|
||||
}
|
||||
|
||||
export function ServerOverviewCard({
|
||||
loggedIn,
|
||||
versionText,
|
||||
versionStatus,
|
||||
uptime,
|
||||
dbHealth,
|
||||
totalServers,
|
||||
totalTunnels,
|
||||
totalCredentials,
|
||||
updateCheckDisabled = false,
|
||||
}: ServerOverviewCardProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="border-2 border-edge rounded-md flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20 !bg-elevated">
|
||||
<div className="flex flex-col mx-3 my-2 overflow-y-auto overflow-x-hidden thin-scrollbar">
|
||||
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
|
||||
<Server className="mr-3" />
|
||||
{t("dashboard.serverOverview")}
|
||||
</p>
|
||||
<div className="w-full h-auto border-2 border-edge rounded-md px-3 py-3 !bg-canvas">
|
||||
<div className="flex flex-row items-center justify-between mb-3 min-w-0 gap-2">
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<History size={20} className="shrink-0" />
|
||||
<p className="ml-2 leading-none truncate">
|
||||
{t("dashboard.version")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center">
|
||||
<p className="leading-none text-muted-foreground">
|
||||
{versionText}
|
||||
</p>
|
||||
{versionStatus === "beta" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-2 text-sm border-1 border-edge text-blue-400"
|
||||
>
|
||||
{t("dashboard.beta")}
|
||||
</Button>
|
||||
) : !updateCheckDisabled ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`ml-2 text-sm border-1 border-edge ${versionStatus === "up_to_date" ? "text-green-400" : "text-yellow-400"}`}
|
||||
>
|
||||
{versionStatus === "up_to_date"
|
||||
? t("dashboard.upToDate")
|
||||
: t("dashboard.updateAvailable")}
|
||||
</Button>
|
||||
<UpdateLog loggedIn={loggedIn} />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center justify-between mb-5 min-w-0 gap-2">
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<Clock size={20} className="shrink-0" />
|
||||
<p className="ml-2 leading-none truncate">
|
||||
{t("dashboard.uptime")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center">
|
||||
<p className="leading-none text-muted-foreground">{uptime}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center justify-between min-w-0 gap-2">
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<Database size={20} className="shrink-0" />
|
||||
<p className="ml-2 leading-none truncate">
|
||||
{t("dashboard.database")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center">
|
||||
<p
|
||||
className={`leading-none ${dbHealth === "healthy" ? "text-green-400" : "text-red-400"}`}
|
||||
>
|
||||
{dbHealth === "healthy"
|
||||
? t("dashboard.healthy")
|
||||
: t("dashboard.error")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col grid grid-cols-2 gap-2 mt-2">
|
||||
<div className="flex flex-row items-center justify-between w-full h-auto mt-3 border-2 border-edge rounded-md px-3 py-3 min-w-0 gap-2 !bg-canvas">
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<Server size={16} className="mr-3 shrink-0" />
|
||||
<p className="m-0 leading-none truncate">
|
||||
{t("dashboard.totalHosts")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="m-0 leading-none text-muted-foreground font-semibold">
|
||||
{totalServers}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-between w-full h-auto mt-3 border-2 border-edge rounded-md px-3 py-3 min-w-0 gap-2 !bg-canvas">
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<ArrowDownUp size={16} className="mr-3 shrink-0" />
|
||||
<p className="m-0 leading-none truncate">
|
||||
{t("dashboard.totalTunnels")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="m-0 leading-none text-muted-foreground font-semibold">
|
||||
{totalTunnels}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col grid grid-cols-2 gap-2 mt-2">
|
||||
<div className="flex flex-row items-center justify-between w-full h-auto mt-3 border-2 border-edge rounded-md px-3 py-3 min-w-0 gap-2 !bg-canvas">
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<Key size={16} className="mr-3 shrink-0" />
|
||||
<p className="m-0 leading-none truncate">
|
||||
{t("dashboard.totalCredentials")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="m-0 leading-none text-muted-foreground font-semibold">
|
||||
{totalCredentials}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChartLine, Loader2, Server } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
|
||||
interface ServerStat {
|
||||
id: number;
|
||||
name: string;
|
||||
cpu: number | null;
|
||||
ram: number | null;
|
||||
}
|
||||
|
||||
interface ServerStatsCardProps {
|
||||
serverStats: ServerStat[];
|
||||
loading: boolean;
|
||||
onServerClick: (serverId: number, serverName: string) => void;
|
||||
}
|
||||
|
||||
export function ServerStatsCard({
|
||||
serverStats,
|
||||
loading,
|
||||
onServerClick,
|
||||
}: ServerStatsCardProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const visibleStats = serverStats.filter(
|
||||
(s) => s.cpu !== null || s.ram !== null,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border-2 border-edge rounded-md flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20 !bg-elevated">
|
||||
<div className="flex flex-col mx-3 my-2 flex-1 overflow-hidden">
|
||||
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
|
||||
<ChartLine className="mr-3" />
|
||||
{t("dashboard.serverStats")}
|
||||
</p>
|
||||
<div
|
||||
className={`grid gap-4 grid-cols-3 auto-rows-min overflow-x-hidden thin-scrollbar ${loading ? "overflow-y-hidden" : "overflow-y-auto"}`}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex flex-row items-center text-muted-foreground text-sm animate-pulse">
|
||||
<Loader2 className="animate-spin mr-2" size={16} />
|
||||
<span>{t("dashboard.loadingServerStats")}</span>
|
||||
</div>
|
||||
) : visibleStats.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("dashboard.noServerData")}
|
||||
</p>
|
||||
) : (
|
||||
visibleStats.map((server) => (
|
||||
<Button
|
||||
key={server.id}
|
||||
variant="outline"
|
||||
className="border-2 !border-edge h-auto p-3 min-w-0 !bg-canvas"
|
||||
onClick={() => onServerClick(server.id, server.name)}
|
||||
>
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex flex-row items-center mb-2">
|
||||
<Server size={20} className="shrink-0" />
|
||||
<p className="truncate ml-2 font-semibold">{server.name}</p>
|
||||
</div>
|
||||
<div className="flex flex-row justify-start gap-4 text-xs text-muted-foreground">
|
||||
{server.cpu !== null && (
|
||||
<span>
|
||||
{t("dashboard.cpu")}: {server.cpu.toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
{server.ram !== null && (
|
||||
<span>
|
||||
{t("dashboard.ram")}: {server.ram.toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/dialog";
|
||||
import { Button } from "@/components/button";
|
||||
import { Label } from "@/components/label";
|
||||
import { Checkbox } from "@/components/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/select";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DashboardLayout } from "@/main-axios";
|
||||
|
||||
interface DashboardSettingsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
currentLayout: DashboardLayout;
|
||||
onSave: (layout: DashboardLayout) => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export function DashboardSettingsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
currentLayout,
|
||||
onSave,
|
||||
onReset,
|
||||
}: DashboardSettingsDialogProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [layout, setLayout] = useState<DashboardLayout>(currentLayout);
|
||||
|
||||
useEffect(() => {
|
||||
setLayout(currentLayout);
|
||||
}, [currentLayout, open]);
|
||||
|
||||
const handleCardToggle = (cardId: string, enabled: boolean) => {
|
||||
setLayout((prev) => ({
|
||||
...prev,
|
||||
cards: prev.cards.map((card) =>
|
||||
card.id === cardId ? { ...card, enabled } : card,
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleCardPanel = (cardId: string, panel: "main" | "side") => {
|
||||
setLayout((prev) => ({
|
||||
...prev,
|
||||
cards: prev.cards.map((card) =>
|
||||
card.id === cardId ? { ...card, panel } : card,
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(layout);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
onReset();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const cardLabels: Record<string, string> = {
|
||||
server_overview: t("dashboard.serverOverviewCard"),
|
||||
recent_activity: t("dashboard.recentActivityCard"),
|
||||
network_graph: t("dashboard.networkGraphCard"),
|
||||
quick_actions: t("dashboard.quickActionsCard"),
|
||||
server_stats: t("dashboard.serverStatsCard"),
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px] bg-canvas border-2 border-edge">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("dashboard.dashboardSettings")}</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
{t("dashboard.customizeLayout")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold">
|
||||
{t("dashboard.enableDisableCards")}
|
||||
</Label>
|
||||
<div className="space-y-3">
|
||||
{layout.cards?.map((card) => (
|
||||
<div
|
||||
key={card.id}
|
||||
className="flex items-center space-x-3 border-2 border-edge rounded-md p-3"
|
||||
>
|
||||
<Checkbox
|
||||
id={card.id}
|
||||
checked={card.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
handleCardToggle(card.id, checked === true)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={card.id}
|
||||
className="text-sm font-normal cursor-pointer flex-1"
|
||||
>
|
||||
{cardLabels[card.id] || card.id}
|
||||
</Label>
|
||||
<Select
|
||||
value={card.panel ?? "main"}
|
||||
onValueChange={(v) =>
|
||||
handleCardPanel(card.id, v as "main" | "side")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-24 h-7 text-xs border-edge">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="main">
|
||||
{t("dashboard.panelMain")}
|
||||
</SelectItem>
|
||||
<SelectItem value="side">
|
||||
{t("dashboard.panelSide")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-row gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
className="border-2 border-edge"
|
||||
>
|
||||
{t("dashboard.resetLayout")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="border-2 border-edge"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSave}>{t("common.save")}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
getDashboardPreferences,
|
||||
saveDashboardPreferences,
|
||||
type DashboardLayout,
|
||||
} from "@/main-axios";
|
||||
|
||||
const LS_KEY = "dashboardLayout";
|
||||
|
||||
const DEFAULT_LAYOUT: DashboardLayout = {
|
||||
cards: [
|
||||
{ id: "server_overview", enabled: true, order: 1, panel: "main" },
|
||||
{ id: "quick_actions", enabled: true, order: 2, panel: "main" },
|
||||
{ id: "server_stats", enabled: true, order: 3, panel: "main" },
|
||||
{ id: "network_graph", enabled: false, order: 4, panel: "main" },
|
||||
{ id: "recent_activity", enabled: true, order: 1, panel: "side" },
|
||||
],
|
||||
mainWidthPct: 68,
|
||||
};
|
||||
|
||||
function migrateLayout(preferences: DashboardLayout): DashboardLayout {
|
||||
const needsMigration = preferences.cards.some((c) => !c.panel);
|
||||
if (!needsMigration) return preferences;
|
||||
const defaultCardMap = new Map(DEFAULT_LAYOUT.cards.map((c) => [c.id, c]));
|
||||
return {
|
||||
...preferences,
|
||||
mainWidthPct: preferences.mainWidthPct ?? DEFAULT_LAYOUT.mainWidthPct,
|
||||
cards: preferences.cards.map((c) => ({
|
||||
...c,
|
||||
panel: c.panel ?? defaultCardMap.get(c.id)?.panel ?? "main",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function readFromLocalStorage(): DashboardLayout | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed?.cards && Array.isArray(parsed.cards)) {
|
||||
return migrateLayout(parsed);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function writeToLocalStorage(layout: DashboardLayout) {
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(layout));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function useDashboardPreferences(enabled: boolean = true) {
|
||||
const [layout, setLayout] = useState<DashboardLayout | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saveTimeout, setSaveTimeout] = useState<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setLayout(DEFAULT_LAYOUT);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show cached layout immediately so the UI doesn't wait for the network
|
||||
const cached = readFromLocalStorage();
|
||||
if (cached) {
|
||||
setLayout(cached);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
const fetchPreferences = async () => {
|
||||
try {
|
||||
const preferences = await getDashboardPreferences();
|
||||
if (preferences?.cards && Array.isArray(preferences.cards)) {
|
||||
const migrated = migrateLayout(preferences);
|
||||
setLayout(migrated);
|
||||
writeToLocalStorage(migrated);
|
||||
} else {
|
||||
if (!cached) {
|
||||
setLayout(DEFAULT_LAYOUT);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!cached) {
|
||||
setLayout(DEFAULT_LAYOUT);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPreferences();
|
||||
}, [enabled]);
|
||||
|
||||
const updateLayout = useCallback(
|
||||
(newLayout: DashboardLayout) => {
|
||||
setLayout(newLayout);
|
||||
writeToLocalStorage(newLayout);
|
||||
|
||||
if (saveTimeout) {
|
||||
clearTimeout(saveTimeout);
|
||||
}
|
||||
|
||||
const timeout = setTimeout(async () => {
|
||||
try {
|
||||
await saveDashboardPreferences(newLayout);
|
||||
} catch (error) {
|
||||
console.error("Failed to save dashboard preferences:", error);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
setSaveTimeout(timeout);
|
||||
},
|
||||
[saveTimeout],
|
||||
);
|
||||
|
||||
const resetLayout = useCallback(async () => {
|
||||
setLayout(DEFAULT_LAYOUT);
|
||||
writeToLocalStorage(DEFAULT_LAYOUT);
|
||||
try {
|
||||
await saveDashboardPreferences(DEFAULT_LAYOUT);
|
||||
} catch (error) {
|
||||
console.error("Failed to reset dashboard preferences:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
layout,
|
||||
loading,
|
||||
updateLayout,
|
||||
resetLayout,
|
||||
};
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { copyToClipboard } from "./clipboard";
|
||||
|
||||
describe("copyToClipboard", () => {
|
||||
const originalClipboard = navigator.clipboard;
|
||||
|
||||
beforeEach(() => {
|
||||
delete (window as { electronClipboard?: unknown }).electronClipboard;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: originalClipboard,
|
||||
configurable: true,
|
||||
});
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function setClipboard(value: unknown) {
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
it("uses the electron bridge when present", async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(true);
|
||||
(window as { electronClipboard?: unknown }).electronClipboard = {
|
||||
writeText,
|
||||
readText: vi.fn(),
|
||||
};
|
||||
|
||||
const ok = await copyToClipboard("hello");
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(writeText).toHaveBeenCalledWith("hello");
|
||||
});
|
||||
|
||||
it("uses navigator.clipboard when available", async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
setClipboard({ writeText });
|
||||
|
||||
const ok = await copyToClipboard("world");
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(writeText).toHaveBeenCalledWith("world");
|
||||
});
|
||||
|
||||
it("falls back to execCommand when navigator.clipboard is undefined (Brave / non-HTTPS)", async () => {
|
||||
setClipboard(undefined);
|
||||
const execCommand = vi.fn().mockReturnValue(true);
|
||||
(document as unknown as { execCommand: typeof execCommand }).execCommand =
|
||||
execCommand;
|
||||
|
||||
const ok = await copyToClipboard("fallback");
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(execCommand).toHaveBeenCalledWith("copy");
|
||||
});
|
||||
|
||||
it("falls back to execCommand when the async write throws", async () => {
|
||||
setClipboard({
|
||||
writeText: vi.fn().mockRejectedValue(new Error("denied")),
|
||||
});
|
||||
const execCommand = vi.fn().mockReturnValue(true);
|
||||
(document as unknown as { execCommand: typeof execCommand }).execCommand =
|
||||
execCommand;
|
||||
|
||||
const ok = await copyToClipboard("retry");
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(execCommand).toHaveBeenCalledWith("copy");
|
||||
});
|
||||
|
||||
it("returns false when every approach fails", async () => {
|
||||
setClipboard(undefined);
|
||||
(document as unknown as { execCommand: () => boolean }).execCommand =
|
||||
() => {
|
||||
throw new Error("no execCommand");
|
||||
};
|
||||
|
||||
const ok = await copyToClipboard("nope");
|
||||
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Copies text to the clipboard, working around environments where the async
|
||||
* Clipboard API is unavailable - notably Brave and any non-HTTPS origin, where
|
||||
* `navigator.clipboard` is undefined. Tries the Electron bridge first, then the
|
||||
* async Clipboard API, then a hidden-textarea execCommand fallback.
|
||||
*
|
||||
* Returns true on success so callers can decide whether to show a success or
|
||||
* error toast.
|
||||
*/
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
if (window.electronClipboard) {
|
||||
try {
|
||||
await window.electronClipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// fall through to browser approaches
|
||||
}
|
||||
}
|
||||
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// fall through to the legacy fallback
|
||||
}
|
||||
}
|
||||
|
||||
return legacyCopy(text);
|
||||
}
|
||||
|
||||
function legacyCopy(text: string): boolean {
|
||||
try {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.setAttribute("readonly", "");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.top = "-9999px";
|
||||
textarea.style.left = "-9999px";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
const ok = document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -9,17 +9,36 @@ describe("highlightTerminalOutput", () => {
|
||||
expect(highlightTerminalOutput(" ")).toBe(" ");
|
||||
});
|
||||
|
||||
it("wraps error keywords in red ANSI codes", () => {
|
||||
it("wraps ERROR in bright red", () => {
|
||||
const out = highlightTerminalOutput("Something ERROR happened");
|
||||
expect(out).toContain(ESC + "[91m");
|
||||
expect(out).toContain(ESC + "[0m");
|
||||
expect(out).toContain("ERROR");
|
||||
});
|
||||
|
||||
it("highlights IPv4 addresses", () => {
|
||||
it("wraps lowercase error in bright red (case-insensitive)", () => {
|
||||
const out = highlightTerminalOutput("something error happened");
|
||||
expect(out).toContain(ESC + "[91m");
|
||||
});
|
||||
|
||||
it("wraps Error (mixed case) in bright red", () => {
|
||||
const out = highlightTerminalOutput("Error: connection refused");
|
||||
expect(out).toContain(ESC + "[91m");
|
||||
});
|
||||
|
||||
it("wraps warn (lowercase) in bright yellow", () => {
|
||||
const out = highlightTerminalOutput("warn: disk almost full");
|
||||
expect(out).toContain(ESC + "[93m");
|
||||
});
|
||||
|
||||
it("wraps warning (lowercase) in bright yellow", () => {
|
||||
const out = highlightTerminalOutput("warning: something is off");
|
||||
expect(out).toContain(ESC + "[93m");
|
||||
});
|
||||
|
||||
it("highlights IPv4 addresses in magenta", () => {
|
||||
const out = highlightTerminalOutput("connect to 192.168.1.10 now");
|
||||
expect(out).toContain("192.168.1.10");
|
||||
expect(out).toContain(ESC + "[35m"); // magenta
|
||||
expect(out).toContain(ESC + "[35m");
|
||||
});
|
||||
|
||||
it("leaves text with no matchable tokens unchanged", () => {
|
||||
@@ -27,20 +46,156 @@ describe("highlightTerminalOutput", () => {
|
||||
expect(highlightTerminalOutput(plain)).toBe(plain);
|
||||
});
|
||||
|
||||
it("does not re-highlight text that already contains many ANSI codes", () => {
|
||||
let heavy = "";
|
||||
for (let i = 0; i < 12; i++) heavy += `${ESC}[32mgreen${ESC}[0m `;
|
||||
expect(highlightTerminalOutput(heavy)).toBe(heavy);
|
||||
});
|
||||
|
||||
it("leaves text ending in an incomplete ANSI escape untouched", () => {
|
||||
const partial = `loading${ESC}[`;
|
||||
expect(highlightTerminalOutput(partial)).toBe(partial);
|
||||
});
|
||||
|
||||
it("does not exceed MAX_LINE_LENGTH processing on huge lines", () => {
|
||||
const huge = "ERROR " + "x".repeat(6000);
|
||||
// Over the 5000 char cap, highlightPlainText returns the input unchanged.
|
||||
it("skips TUI cursor-positioning frames (nano/vim protection)", () => {
|
||||
const tuiFrame = `${ESC}[H${ESC}[2J some nano content`;
|
||||
expect(highlightTerminalOutput(tuiFrame)).toBe(tuiFrame);
|
||||
});
|
||||
|
||||
it("highlights text that already has ANSI codes", () => {
|
||||
let heavy = "";
|
||||
for (let i = 0; i < 12; i++) heavy += `${ESC}[32mgreen${ESC}[0m `;
|
||||
heavy += "ERROR at line 5";
|
||||
const out = highlightTerminalOutput(heavy);
|
||||
expect(out).toContain(ESC + "[91m");
|
||||
});
|
||||
|
||||
it("does not process lines exceeding MAX_LINE_LENGTH", () => {
|
||||
const huge = "ERROR " + "x".repeat(3000);
|
||||
expect(highlightTerminalOutput(huge)).toBe(huge);
|
||||
});
|
||||
|
||||
it("highlights simple absolute paths in cyan", () => {
|
||||
const out = highlightTerminalOutput("log at /var/log/nginx/access.log");
|
||||
expect(out).toContain(ESC + "[36m");
|
||||
expect(out).toContain("/var/log/nginx/access.log");
|
||||
});
|
||||
|
||||
it("highlights absolute paths with glob wildcards in cyan", () => {
|
||||
const out = highlightTerminalOutput("files: /usr/share/doc/*/copyright");
|
||||
expect(out).toContain(ESC + "[36m");
|
||||
expect(out).toContain("/usr/share/doc/*/copyright");
|
||||
});
|
||||
|
||||
it("highlights home paths in cyan", () => {
|
||||
const out = highlightTerminalOutput("file: ~/documents/notes.txt");
|
||||
expect(out).toContain(ESC + "[36m");
|
||||
expect(out).toContain("~/documents/notes.txt");
|
||||
});
|
||||
|
||||
it("highlights bracket timestamps in bright black", () => {
|
||||
const out = highlightTerminalOutput("[12:34:56] server started");
|
||||
expect(out).toContain(ESC + "[90m");
|
||||
expect(out).toContain("[12:34:56]");
|
||||
});
|
||||
|
||||
it("does not highlight out-of-range bracket timestamps", () => {
|
||||
// [99:99] is not a valid time
|
||||
const out = highlightTerminalOutput("[99:99] something");
|
||||
expect(out).not.toContain(ESC + "[90m");
|
||||
});
|
||||
|
||||
it("highlights ISO date timestamps in bright black", () => {
|
||||
const out = highlightTerminalOutput("2024-01-15 event occurred");
|
||||
expect(out).toContain(ESC + "[90m");
|
||||
expect(out).toContain("2024-01-15");
|
||||
});
|
||||
|
||||
it("does not highlight version strings as ISO dates", () => {
|
||||
// 2.4.0 or 1.2024.3 should not trigger ISO date
|
||||
const out = highlightTerminalOutput("version 2.4.0 released");
|
||||
expect(out).toBe("version 2.4.0 released");
|
||||
});
|
||||
|
||||
it("highlights labeled numbers (port)", () => {
|
||||
const out = highlightTerminalOutput("listening on port 8080");
|
||||
expect(out).toContain(ESC + "[96m");
|
||||
expect(out).toContain("8080");
|
||||
});
|
||||
|
||||
it("highlights labeled numbers (exit)", () => {
|
||||
const out = highlightTerminalOutput("process exited with exit 1");
|
||||
expect(out).toContain(ESC + "[96m");
|
||||
});
|
||||
|
||||
it("highlights labeled numbers (status)", () => {
|
||||
const out = highlightTerminalOutput("returned status 404");
|
||||
expect(out).toContain(ESC + "[96m");
|
||||
});
|
||||
|
||||
it("does not highlight standalone numbers outside labeled context", () => {
|
||||
const out = highlightTerminalOutput("there are 42 files here");
|
||||
expect(out).toBe("there are 42 files here");
|
||||
});
|
||||
|
||||
it("does not highlight 'up' or 'active' as success", () => {
|
||||
const out = highlightTerminalOutput("service is up and running");
|
||||
expect(out).not.toContain(ESC + "[92m");
|
||||
});
|
||||
|
||||
it("highlights unambiguous success keywords (case-insensitive)", () => {
|
||||
const out = highlightTerminalOutput("Test passed successfully");
|
||||
expect(out).toContain(ESC + "[92m");
|
||||
});
|
||||
|
||||
it("highlights URLs with blue+underline", () => {
|
||||
const out = highlightTerminalOutput("visit https://example.com now");
|
||||
expect(out).toContain(ESC + "[34m");
|
||||
expect(out).toContain(ESC + "[4m");
|
||||
});
|
||||
|
||||
it("preserves \\r in CRLF terminal output", () => {
|
||||
const out = highlightTerminalOutput("ERROR occurred\r\n");
|
||||
expect(out).toContain("\r\n");
|
||||
});
|
||||
|
||||
it("processes multi-line text line by line", () => {
|
||||
const text = "some output\nERROR: failed";
|
||||
const out = highlightTerminalOutput(text);
|
||||
expect(out).toContain(ESC + "[91m");
|
||||
});
|
||||
|
||||
it("respects category options - disabling logLevels skips error highlight", () => {
|
||||
const out = highlightTerminalOutput("ERROR occurred", { logLevels: false });
|
||||
expect(out).not.toContain(ESC + "[91m");
|
||||
});
|
||||
|
||||
it("respects category options - disabling paths skips path highlight", () => {
|
||||
const out = highlightTerminalOutput("at /usr/share/doc/something/file", {
|
||||
paths: false,
|
||||
});
|
||||
expect(out).not.toContain(ESC + "[36m");
|
||||
});
|
||||
|
||||
it("respects category options - disabling urls skips url highlight", () => {
|
||||
const out = highlightTerminalOutput("see https://example.com", {
|
||||
urls: false,
|
||||
});
|
||||
expect(out).not.toContain(ESC + "[34m");
|
||||
});
|
||||
|
||||
it("respects category options - disabling ipAddresses skips IP highlight", () => {
|
||||
const out = highlightTerminalOutput("connect to 192.168.1.1", {
|
||||
ipAddresses: false,
|
||||
});
|
||||
expect(out).not.toContain(ESC + "[35m");
|
||||
});
|
||||
|
||||
it("respects category options - disabling timestamps skips ISO date highlight", () => {
|
||||
const out = highlightTerminalOutput("2024-01-15 something", {
|
||||
timestamps: false,
|
||||
});
|
||||
expect(out).not.toContain(ESC + "[90m");
|
||||
});
|
||||
|
||||
it("respects category options - disabling numbers skips port highlight", () => {
|
||||
const out = highlightTerminalOutput("listening on port 8080", {
|
||||
numbers: false,
|
||||
});
|
||||
expect(out).not.toContain(ESC + "[96m");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
const ANSI_CODES = {
|
||||
const ANSI = {
|
||||
reset: "\x1b[0m",
|
||||
colors: {
|
||||
red: "\x1b[31m",
|
||||
green: "\x1b[32m",
|
||||
yellow: "\x1b[33m",
|
||||
blue: "\x1b[34m",
|
||||
magenta: "\x1b[35m",
|
||||
cyan: "\x1b[36m",
|
||||
white: "\x1b[37m",
|
||||
brightBlack: "\x1b[90m",
|
||||
brightRed: "\x1b[91m",
|
||||
brightGreen: "\x1b[92m",
|
||||
brightYellow: "\x1b[93m",
|
||||
brightBlue: "\x1b[94m",
|
||||
brightMagenta: "\x1b[95m",
|
||||
brightCyan: "\x1b[96m",
|
||||
brightWhite: "\x1b[97m",
|
||||
},
|
||||
styles: {
|
||||
bold: "\x1b[1m",
|
||||
dim: "\x1b[2m",
|
||||
italic: "\x1b[3m",
|
||||
underline: "\x1b[4m",
|
||||
},
|
||||
red: "\x1b[31m",
|
||||
green: "\x1b[32m",
|
||||
yellow: "\x1b[33m",
|
||||
blue: "\x1b[34m",
|
||||
magenta: "\x1b[35m",
|
||||
cyan: "\x1b[36m",
|
||||
brightBlack: "\x1b[90m",
|
||||
brightRed: "\x1b[91m",
|
||||
brightGreen: "\x1b[92m",
|
||||
brightYellow: "\x1b[93m",
|
||||
brightBlue: "\x1b[94m",
|
||||
brightCyan: "\x1b[96m",
|
||||
bold: "\x1b[1m",
|
||||
underline: "\x1b[4m",
|
||||
} as const;
|
||||
|
||||
export interface SyntaxHighlightOptions {
|
||||
logLevels?: boolean;
|
||||
paths?: boolean;
|
||||
timestamps?: boolean;
|
||||
ipAddresses?: boolean;
|
||||
urls?: boolean;
|
||||
numbers?: boolean;
|
||||
}
|
||||
|
||||
interface HighlightPattern {
|
||||
name: string;
|
||||
regex: RegExp;
|
||||
ansiCode: string;
|
||||
priority: number;
|
||||
quickCheck?: string;
|
||||
category: keyof SyntaxHighlightOptions;
|
||||
}
|
||||
|
||||
interface MatchResult {
|
||||
@@ -38,226 +38,308 @@ interface MatchResult {
|
||||
end: number;
|
||||
ansiCode: string;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
const MAX_LINE_LENGTH = 5000;
|
||||
const MAX_ANSI_CODES = 10;
|
||||
|
||||
const PATTERNS: HighlightPattern[] = [
|
||||
{
|
||||
name: "ipv4",
|
||||
regex:
|
||||
/(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?::\d{1,5})?/g,
|
||||
ansiCode: ANSI_CODES.colors.magenta,
|
||||
priority: 10,
|
||||
},
|
||||
|
||||
{
|
||||
name: "log-error",
|
||||
regex:
|
||||
/\b(ERROR|FATAL|CRITICAL|FAIL(?:ED)?|denied|invalid|DENIED)\b|\[ERROR\]/gi,
|
||||
ansiCode: ANSI_CODES.colors.brightRed,
|
||||
priority: 9,
|
||||
},
|
||||
|
||||
{
|
||||
name: "log-warn",
|
||||
regex: /\b(WARN(?:ING)?|ALERT)\b|\[WARN(?:ING)?\]/gi,
|
||||
ansiCode: ANSI_CODES.colors.yellow,
|
||||
priority: 9,
|
||||
},
|
||||
|
||||
{
|
||||
name: "log-success",
|
||||
regex:
|
||||
/\b(SUCCESS|OK|PASS(?:ED)?|COMPLETE(?:D)?|connected|active|up|Up|UP|FULL)\b/gi,
|
||||
ansiCode: ANSI_CODES.colors.brightGreen,
|
||||
priority: 8,
|
||||
},
|
||||
|
||||
{
|
||||
name: "url",
|
||||
regex: /https?:\/\/[^\s\])}]+/g,
|
||||
ansiCode: `${ANSI_CODES.colors.blue}${ANSI_CODES.styles.underline}`,
|
||||
priority: 8,
|
||||
},
|
||||
|
||||
{
|
||||
name: "path-absolute",
|
||||
regex: /\/[a-zA-Z][a-zA-Z0-9_\-@.]*(?:\/[a-zA-Z0-9_\-@.]+)+/g,
|
||||
ansiCode: ANSI_CODES.colors.cyan,
|
||||
priority: 7,
|
||||
},
|
||||
|
||||
{
|
||||
name: "path-home",
|
||||
regex: /~\/[a-zA-Z0-9_\-@./]+/g,
|
||||
ansiCode: ANSI_CODES.colors.cyan,
|
||||
priority: 7,
|
||||
},
|
||||
|
||||
{
|
||||
name: "log-info",
|
||||
regex: /\bINFO\b|\[INFO\]/gi,
|
||||
ansiCode: ANSI_CODES.colors.blue,
|
||||
priority: 6,
|
||||
},
|
||||
{
|
||||
name: "log-debug",
|
||||
regex: /\b(?:DEBUG|TRACE)\b|\[(?:DEBUG|TRACE)\]/gi,
|
||||
ansiCode: ANSI_CODES.colors.brightBlack,
|
||||
priority: 6,
|
||||
},
|
||||
];
|
||||
|
||||
function hasExistingAnsiCodes(text: string): boolean {
|
||||
const ansiCount = (
|
||||
text.match(
|
||||
/\x1b[[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-PRZcf-nq-uy=><~]/g,
|
||||
) || []
|
||||
).length;
|
||||
return ansiCount > MAX_ANSI_CODES;
|
||||
}
|
||||
|
||||
function hasIncompleteAnsiSequence(text: string): boolean {
|
||||
return /\x1b(?:\[(?:[0-9;?>=!]*)?)?$/.test(text);
|
||||
// capture group offset: some patterns capture a sub-group rather than whole match
|
||||
captureStart?: number;
|
||||
captureEnd?: number;
|
||||
}
|
||||
|
||||
interface TextSegment {
|
||||
isAnsi: boolean;
|
||||
content: string;
|
||||
// the last active SGR code before this segment (for state restoration)
|
||||
activeSgr?: string;
|
||||
}
|
||||
|
||||
const MAX_LINE_LENGTH = 2000;
|
||||
|
||||
// Cursor-positioning and erase sequences used by TUI apps (nano, vim, htop).
|
||||
// If a chunk contains these, we skip highlighting entirely.
|
||||
const TUI_SEQUENCE = /\x1b\[[\d;]*[ABCDEFGHJKST]/;
|
||||
|
||||
// Matches any complete ANSI escape sequence
|
||||
const ANSI_REGEX = /\x1b(?:[@-Z\\-_]|\[[0-9;?>=!]*[@-~])/g;
|
||||
|
||||
// Matches SGR sequences (color/style setters) specifically — used to track active color state
|
||||
const SGR_REGEX = /\x1b\[[0-9;]*m/;
|
||||
|
||||
// All patterns, ordered roughly by specificity. Priority determines which wins on overlap.
|
||||
const ALL_PATTERNS: HighlightPattern[] = [
|
||||
// IPv4 with optional :port — well-bounded, very specific
|
||||
{
|
||||
name: "ipv4",
|
||||
regex:
|
||||
/(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?::\d{1,5})?/g,
|
||||
ansiCode: ANSI.magenta,
|
||||
priority: 10,
|
||||
category: "ipAddresses",
|
||||
},
|
||||
|
||||
// Bracket timestamps [HH:MM] or [HH:MM:SS] — tight range checks prevent false positives
|
||||
{
|
||||
name: "timestamp-bracket",
|
||||
regex: /\[(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?\]/g,
|
||||
ansiCode: ANSI.brightBlack,
|
||||
priority: 9,
|
||||
category: "timestamps",
|
||||
},
|
||||
|
||||
// ISO 8601 date with optional time. Require non-digit before to avoid matching
|
||||
// inside version strings like "1.2024.3". The \b on the right ensures we don't
|
||||
// match partial tokens.
|
||||
{
|
||||
name: "timestamp-iso",
|
||||
regex:
|
||||
/(?<![0-9])\b\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])(?:[T ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?\b/g,
|
||||
ansiCode: ANSI.brightBlack,
|
||||
priority: 9,
|
||||
category: "timestamps",
|
||||
},
|
||||
|
||||
// Error-level keywords — case-insensitive, catches "Error", "error", "FAILED", etc.
|
||||
{
|
||||
name: "log-error",
|
||||
regex:
|
||||
/\b(?:error|fatal|critical|fail(?:ed)?|denied|exception)\b|\[(?:error|fatal|critical)\]/gi,
|
||||
ansiCode: ANSI.brightRed,
|
||||
priority: 9,
|
||||
category: "logLevels",
|
||||
},
|
||||
|
||||
// Warning-level keywords — case-insensitive
|
||||
{
|
||||
name: "log-warn",
|
||||
regex: /\b(?:warn(?:ing)?|alert|caution)\b|\[warn(?:ing)?\]/gi,
|
||||
ansiCode: ANSI.brightYellow,
|
||||
priority: 9,
|
||||
category: "logLevels",
|
||||
},
|
||||
|
||||
// Success keywords — kept conservative to avoid noise
|
||||
{
|
||||
name: "log-success",
|
||||
regex: /\b(?:success(?:ful(?:ly)?)?|pass(?:ed)?|complete(?:d)?|ok\b)\b/gi,
|
||||
ansiCode: ANSI.brightGreen,
|
||||
priority: 8,
|
||||
category: "logLevels",
|
||||
},
|
||||
|
||||
// URLs — blue + underline
|
||||
{
|
||||
name: "url",
|
||||
regex: /https?:\/\/[^\s\])}>"']+/g,
|
||||
ansiCode: `${ANSI.blue}${ANSI.underline}`,
|
||||
priority: 8,
|
||||
category: "urls",
|
||||
},
|
||||
|
||||
// Absolute paths — permissive character class that allows *, ?, [], globs, etc.
|
||||
// Requires at least one slash after the root segment so we don't match bare /dev or /tmp.
|
||||
// Boundary: must start at beginning of text or after whitespace/colon/comma/paren.
|
||||
{
|
||||
name: "path-absolute",
|
||||
regex:
|
||||
/(?:(?<=^)|(?<=[\s:,;('"]))\/[^\s"'`|<>&;\\]+(?:\/[^\s"'`|<>&;\\]+)+/g,
|
||||
ansiCode: ANSI.cyan,
|
||||
priority: 7,
|
||||
category: "paths",
|
||||
},
|
||||
|
||||
// Home-relative paths
|
||||
{
|
||||
name: "path-home",
|
||||
regex: /~\/[^\s"'`|<>&;(){}[\]\\]+/g,
|
||||
ansiCode: ANSI.cyan,
|
||||
priority: 7,
|
||||
category: "paths",
|
||||
},
|
||||
|
||||
// Info keyword
|
||||
{
|
||||
name: "log-info",
|
||||
regex: /\binfo\b|\[info\]/gi,
|
||||
ansiCode: ANSI.blue,
|
||||
priority: 6,
|
||||
category: "logLevels",
|
||||
},
|
||||
|
||||
// Debug/trace
|
||||
{
|
||||
name: "log-debug",
|
||||
regex: /\b(?:debug|trace|verbose)\b|\[(?:debug|trace)\]/gi,
|
||||
ansiCode: ANSI.brightBlack,
|
||||
priority: 6,
|
||||
category: "logLevels",
|
||||
},
|
||||
|
||||
// Labeled numbers: port 8080, exit 1, code 127, status 404, signal 9, returned 0
|
||||
// Captures just the number portion so only the digit is highlighted.
|
||||
{
|
||||
name: "number-labeled",
|
||||
regex: /\b(?:port|exit|code|status|signal|returned?)\s+(\d+)\b/gi,
|
||||
ansiCode: ANSI.brightCyan,
|
||||
priority: 5,
|
||||
category: "numbers",
|
||||
},
|
||||
];
|
||||
|
||||
function hasIncompleteAnsiSequence(text: string): boolean {
|
||||
return /\x1b\[[0-9;?>=!]*$/.test(text);
|
||||
}
|
||||
|
||||
function parseAnsiSegments(text: string): TextSegment[] {
|
||||
const segments: TextSegment[] = [];
|
||||
const ansiRegex = /\x1b(?:[@-Z\\-_]|\[[0-9;?>=!]*[@-~])/g;
|
||||
ANSI_REGEX.lastIndex = 0;
|
||||
let lastIndex = 0;
|
||||
let activeSgr = "";
|
||||
let match;
|
||||
|
||||
while ((match = ansiRegex.exec(text)) !== null) {
|
||||
while ((match = ANSI_REGEX.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
segments.push({
|
||||
isAnsi: false,
|
||||
content: text.slice(lastIndex, match.index),
|
||||
activeSgr,
|
||||
});
|
||||
}
|
||||
|
||||
segments.push({
|
||||
isAnsi: true,
|
||||
content: match[0],
|
||||
});
|
||||
|
||||
lastIndex = ansiRegex.lastIndex;
|
||||
const seq = match[0];
|
||||
segments.push({ isAnsi: true, content: seq });
|
||||
// Track the active SGR state so we can restore it after a highlight reset
|
||||
if (SGR_REGEX.test(seq)) {
|
||||
activeSgr = seq === ANSI.reset ? "" : seq;
|
||||
}
|
||||
lastIndex = ANSI_REGEX.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
segments.push({
|
||||
isAnsi: false,
|
||||
content: text.slice(lastIndex),
|
||||
});
|
||||
segments.push({ isAnsi: false, content: text.slice(lastIndex), activeSgr });
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function highlightPlainText(text: string): string {
|
||||
if (text.length > MAX_LINE_LENGTH) {
|
||||
return text;
|
||||
}
|
||||
|
||||
if (!text.trim()) {
|
||||
return text;
|
||||
}
|
||||
function highlightPlainText(
|
||||
text: string,
|
||||
activePatterns: HighlightPattern[],
|
||||
activeSgr: string,
|
||||
): string {
|
||||
if (text.length > MAX_LINE_LENGTH || !text.trim()) return text;
|
||||
|
||||
const matches: MatchResult[] = [];
|
||||
|
||||
for (const pattern of PATTERNS) {
|
||||
for (const pattern of activePatterns) {
|
||||
pattern.regex.lastIndex = 0;
|
||||
|
||||
let match;
|
||||
while ((match = pattern.regex.exec(text)) !== null) {
|
||||
matches.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
ansiCode: pattern.ansiCode,
|
||||
priority: pattern.priority,
|
||||
});
|
||||
let m;
|
||||
while ((m = pattern.regex.exec(text)) !== null) {
|
||||
// For patterns with a capture group (labeled numbers), highlight only the capture
|
||||
if (m[1] !== undefined) {
|
||||
const captureOffset = m[0].indexOf(m[1], m[0].search(/\d/));
|
||||
const captureStart = m.index + captureOffset;
|
||||
const captureEnd = captureStart + m[1].length;
|
||||
matches.push({
|
||||
start: m.index,
|
||||
end: m.index + m[0].length,
|
||||
ansiCode: pattern.ansiCode,
|
||||
priority: pattern.priority,
|
||||
captureStart,
|
||||
captureEnd,
|
||||
});
|
||||
} else {
|
||||
matches.push({
|
||||
start: m.index,
|
||||
end: m.index + m[0].length,
|
||||
ansiCode: pattern.ansiCode,
|
||||
priority: pattern.priority,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length === 0) {
|
||||
return text;
|
||||
}
|
||||
if (matches.length === 0) return text;
|
||||
|
||||
matches.sort((a, b) => {
|
||||
if (a.priority !== b.priority) {
|
||||
return b.priority - a.priority;
|
||||
}
|
||||
return a.start - b.start;
|
||||
});
|
||||
// Highest priority wins; ties broken by earlier start position
|
||||
matches.sort((a, b) =>
|
||||
a.priority !== b.priority ? b.priority - a.priority : a.start - b.start,
|
||||
);
|
||||
|
||||
const appliedRanges: Array<{ start: number; end: number }> = [];
|
||||
const finalMatches = matches.filter((match) => {
|
||||
const overlaps = appliedRanges.some(
|
||||
(range) =>
|
||||
(match.start >= range.start && match.start < range.end) ||
|
||||
(match.end > range.start && match.end <= range.end) ||
|
||||
(match.start <= range.start && match.end >= range.end),
|
||||
const used: Array<{ start: number; end: number }> = [];
|
||||
const final = matches.filter((m) => {
|
||||
const overlaps = used.some(
|
||||
(r) =>
|
||||
(m.start >= r.start && m.start < r.end) ||
|
||||
(m.end > r.start && m.end <= r.end) ||
|
||||
(m.start <= r.start && m.end >= r.end),
|
||||
);
|
||||
|
||||
if (!overlaps) {
|
||||
appliedRanges.push({ start: match.start, end: match.end });
|
||||
used.push({ start: m.start, end: m.end });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
let result = text;
|
||||
finalMatches.reverse().forEach((match) => {
|
||||
const before = result.slice(0, match.start);
|
||||
const matched = result.slice(match.start, match.end);
|
||||
const after = result.slice(match.end);
|
||||
// Apply in reverse order so indices stay valid as we insert escape codes
|
||||
final.sort((a, b) => a.start - b.start).reverse();
|
||||
|
||||
result = before + match.ansiCode + matched + ANSI_CODES.reset + after;
|
||||
});
|
||||
let result = text;
|
||||
for (const m of final) {
|
||||
const hs = m.captureStart ?? m.start;
|
||||
const he = m.captureEnd ?? m.end;
|
||||
|
||||
// After reset, re-emit the SGR that was active before this plain-text segment
|
||||
// so we don't clobber colors set by the server earlier in the line.
|
||||
const restore = activeSgr || "";
|
||||
result =
|
||||
result.slice(0, hs) +
|
||||
m.ansiCode +
|
||||
result.slice(hs, he) +
|
||||
ANSI.reset +
|
||||
restore +
|
||||
result.slice(he);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function highlightTerminalOutput(text: string): string {
|
||||
if (!text || !text.trim()) {
|
||||
return text;
|
||||
}
|
||||
|
||||
if (hasIncompleteAnsiSequence(text)) {
|
||||
return text;
|
||||
}
|
||||
|
||||
if (hasExistingAnsiCodes(text)) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const segments = parseAnsiSegments(text);
|
||||
|
||||
if (segments.length === 0) {
|
||||
return highlightPlainText(text);
|
||||
}
|
||||
|
||||
const highlightedSegments = segments.map((segment) => {
|
||||
if (segment.isAnsi) {
|
||||
return segment.content;
|
||||
} else {
|
||||
return highlightPlainText(segment.content);
|
||||
}
|
||||
});
|
||||
|
||||
return highlightedSegments.join("");
|
||||
function buildActivePatterns(
|
||||
options: SyntaxHighlightOptions,
|
||||
): HighlightPattern[] {
|
||||
return ALL_PATTERNS.filter((p) => options[p.category] !== false);
|
||||
}
|
||||
|
||||
export function isSyntaxHighlightingEnabled(): boolean {
|
||||
try {
|
||||
return localStorage.getItem("terminalSyntaxHighlighting") === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
function highlightLine(
|
||||
line: string,
|
||||
activePatterns: HighlightPattern[],
|
||||
): string {
|
||||
const cr = line.endsWith("\r");
|
||||
const bare = cr ? line.slice(0, -1) : line;
|
||||
|
||||
if (!bare.trim()) return line;
|
||||
|
||||
const segments = parseAnsiSegments(bare);
|
||||
const result = segments
|
||||
.map((s) =>
|
||||
s.isAnsi
|
||||
? s.content
|
||||
: highlightPlainText(s.content, activePatterns, s.activeSgr ?? ""),
|
||||
)
|
||||
.join("");
|
||||
|
||||
return cr ? result + "\r" : result;
|
||||
}
|
||||
|
||||
export function highlightTerminalOutput(
|
||||
text: string,
|
||||
options: SyntaxHighlightOptions = {},
|
||||
): string {
|
||||
if (!text || !text.trim()) return text;
|
||||
if (hasIncompleteAnsiSequence(text)) return text;
|
||||
|
||||
if (TUI_SEQUENCE.test(text)) return text;
|
||||
|
||||
const activePatterns = buildActivePatterns(options);
|
||||
if (activePatterns.length === 0) return text;
|
||||
|
||||
return text
|
||||
.split("\n")
|
||||
.map((line) => highlightLine(line, activePatterns))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user