release-2.7.0 (#1264)

* feat: redesign host/credential sidebars with synced preferences and manual drag-to-reorder

* chore: run format

* chore(deps-dev): bump @types/pg in the dev-patch-updates group (#1162)

Bumps the dev-patch-updates group with 1 update: [@types/pg](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/pg).


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

---
updated-dependencies:
- dependency-name: "@types/pg"
  dependency-version: 8.20.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps-dev): bump the dev-minor-updates group with 4 updates (#1163)

Bumps the dev-minor-updates group with 4 updates: [react-hook-form](https://github.com/react-hook-form/react-hook-form), [react-icons](https://github.com/react-icons/react-icons), [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) and [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `react-hook-form` from 7.79.0 to 7.84.0
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.79.0...v7.84.0)

Updates `react-icons` from 5.6.0 to 5.7.0
- [Release notes](https://github.com/react-icons/react-icons/releases)
- [Commits](https://github.com/react-icons/react-icons/compare/v5.6.0...v5.7.0)

Updates `typescript-eslint` from 8.61.1 to 8.66.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.66.0/packages/typescript-eslint)

Updates `vite` from 8.0.16 to 8.2.0
- [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/create-vite@8.2.0/packages/vite)

---
updated-dependencies:
- dependency-name: react-hook-form
  dependency-version: 7.84.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: react-icons
  dependency-version: 5.7.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: typescript-eslint
  dependency-version: 8.66.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: vite
  dependency-version: 8.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump the prod-patch-updates group with 3 updates (#1164)

Bumps the prod-patch-updates group with 3 updates: [jose](https://github.com/panva/jose), [js-yaml](https://github.com/nodeca/js-yaml) and [nanoid](https://github.com/ai/nanoid).


Updates `jose` from 6.2.7 to 6.2.8
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v6.2.7...v6.2.8)

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

Updates `nanoid` from 6.0.0 to 6.0.1
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/6.0.0...6.0.1)

---
updated-dependencies:
- dependency-name: jose
  dependency-version: 6.2.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: js-yaml
  dependency-version: 5.2.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: nanoid
  dependency-version: 6.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump undici in the prod-minor-updates group (#1165)

Bumps the prod-minor-updates group with 1 update: [undici](https://github.com/nodejs/undici).


Updates `undici` from 8.9.0 to 8.10.0
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.9.0...v8.10.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 8.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps-dev): bump the major-updates group with 2 updates (#1166)

Bumps the major-updates group with 2 updates: [@types/better-sqlite3](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/better-sqlite3) and [jsdom](https://github.com/jsdom/jsdom).


Updates `@types/better-sqlite3` from 7.6.13 to 9.6.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/better-sqlite3)

Updates `jsdom` from 29.1.1 to 30.0.1
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Commits](https://github.com/jsdom/jsdom/compare/v29.1.1...v30.0.1)

---
updated-dependencies:
- dependency-name: "@types/better-sqlite3"
  dependency-version: 9.6.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: jsdom
  dependency-version: 30.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* fix: stop resyncAutoIncrement failing on tables without an id column (#1173)

The Postgres branch asked pg_get_serial_sequence(table, 'id') about every
table a fixture had inserted into. That function raises 42703 when the
column does not exist, rather than returning null, so any seed touching a
table keyed on something else took down the fixture.

host_sidebar_preferences is keyed on user_id and has no id at all, which
is why the Postgres job on dev-2.7.0 fails for every pull request.

Drive the lookup from information_schema so a missing id column yields no
row instead of an error. A text primary key still returns a null sequence
and is still skipped, as before.

* chore: install the git hooks that were already configured (#1174)

husky, lint-staged, commitlint and their config have been in the repo
since v1.8.0 (#429): .husky/pre-commit runs lint-staged, .husky/commit-msg
runs commitlint, the lint-staged globs are in package.json and the
commitlint rules in .commitlintrc.json.

None of it has ever run. husky only takes effect once it sets
core.hooksPath, and that happens in the prepare lifecycle script, which
the package did not define -- so every clone installed the tooling and
left the hooks unwired.

That is why formatting keeps failing in CI rather than locally: three of
the four open pull requests fail lint-and-build on prettier alone,
touching between one and five files each, and the check is the first place
anyone finds out.

prepare falls back to true so a checkout without a .git directory cannot
break installation. The Docker build passes --ignore-scripts, so it never
runs this at all.

Also pin the Prettier extension to the repo's own copy via
prettier.prettierPath, and let .vscode/settings.json out of .gitignore so
it applies to everyone. The extension bundles its own prettier otherwise,
which formats to a different version's rules than the one CI enforces.

* fix: derive the ssh_credentials rebuild from the live schema (#1172)

The startup rebuild that drops the old username NOT NULL constraint
restated the table's columns as a literal and then copied rows with
INSERT INTO temp SELECT <every live column>. The table has gained columns
since that literal was written — cert_public_key, pin, sort_order and
sync_id are all added by addColumnIfNotExists before the rebuild runs —
so the destination was narrower than the source. SQLite rejected the
INSERT on a column count mismatch, the error was swallowed as a warning,
and the constraint survived every restart.

Read the CREATE TABLE statement back from sqlite_master and rewrite just
the table name and the username constraint, so the replacement table
cannot fall behind the real one. Copy rows by explicit column name rather
than positionally, and replay the table's indexes, which DROP TABLE would
otherwise take with it along with the sync_id uniqueness.

* fix: make audit_logs.user_id nullable on fresh SQLite installs (#1171)

The audit trail is meant to outlive the account it belongs to: deleting a
user nulls user_id and keeps username for attribution. schema.ts, the
Drizzle migrations and AuditLogRepository.anonymizeByUserId were all
written against that, but the runtime bootstrap still created
user_id TEXT NOT NULL.

A second CREATE TABLE IF NOT EXISTS further down migrateSchema() had the
correct nullable column, but it can never run — the primary bootstrap has
already created the table, so IF NOT EXISTS is a no-op. Every fresh
install therefore got the old constraint, and user deletion failed with
"NOT NULL constraint failed: audit_logs.user_id" for any account that had
logged in at least once, via both the admin delete path and the OIDC
account-link cleanup.

Fix the primary bootstrap, and rebuild the table on existing databases
using the same pattern already used for ssh_credentials.username, since
SQLite cannot ALTER a column.

* fix: key the sync upsert on the row it just looked up (#1175)

A sync push locates the stored row twice -- once to decide insert vs
update, once to write it -- and the two lookups were spelled out
separately. Only the read knew about singleton entities; the write always
keyed on table.id.

userPreferences is the only singleton, and user_preferences is the one
synced table with no id column: its primary key is user_id. table.id was
therefore undefined, and drizzle emitted a comparison with nothing on its
left:

  ( = ? and "user_preferences"."user_id" = ?)

The insert branch was unaffected, so the first push of preferences
succeeded and every push after it -- the steady state -- failed with
SqliteError: near "=": syntax error. Preference sync never converged, and
both sides ship the same handler, so the desktop's embedded backend failed
identically.

Extract the lookup into locateSyncRow() and use it for the read, the
update and the tombstone delete, so the three cannot drift apart again.
The tombstone path already handled singletons correctly; it now shares the
one expression rather than keeping a third copy of it.

* fix: refuse an SSH connection whose host id resolves elsewhere (#1176)

A client identifies a host by the numeric row id of the database it is
displaying. With the desktop connection origin set to "Remote server",
that id is resolved against the sync server's ssh_data instead, and the
two autoincrement sequences need not line up -- they diverge as soon as
each side accumulates inserts and deletes in a different order.

resolveHostById() then returns whichever row owns that id here, and the
handler takes the address, the credentials, the jump hosts and the stored
host key from it. The session opens on a machine the user did not pick,
while the host list, host details and export all keep showing the right
one. Commands run on the wrong server, a host key mismatch is reported
for the wrong reason, and anything typed at the prompt goes to the wrong
place.

Compare the resolved address against the one the client sent, and refuse
when they disagree. Checking at the point the row is loaded covers every
use of it rather than each site separately. Addresses are compared with
brackets stripped and casing folded, so an IPv6 literal or a hostname
written differently is not treated as a different machine; when the
server has no address stored, the client's own details are used as
before.

This stops the wrong-machine session. It does not make delegated
connections work when the ids have drifted -- that needs the host to be
addressed by syncId across the boundary, which the connection protocol
does not currently carry.

* fix: refuse SFTP and Docker console on a mismatched host id too (#1177)

The wrong-machine guard added for SSH covered one of the paths that
resolve a client-supplied host id against this server's ssh_data. The
file manager and the Docker console take the same id from the same client
and dial whatever row owns it here.

The file manager then browses, edits and deletes files on that machine,
and the Docker console attaches to its daemon -- both while the UI shows
the host the user actually picked.

Reuse hostAddressMismatch at each point the row is loaded. The two file
manager sites sit inside "failed to resolve credentials, carry on"
handlers, so the refusal is a distinct error type those catches rethrow;
swallowing it would resume the connection this is meant to stop. The
Docker console reports over its socket, as it does for every other
refusal.

The user-facing wording now lives next to the check instead of being
written out at each site.

Still uncovered, and not fixable this way: file-manager's transfer
session, jump-host-chain and the proxmox routes resolve an id with no
client-supplied address to compare it against. Those need the host to be
addressed by syncId across the boundary.

* feat: address hosts by syncId when a connection is delegated (#1178)

A numeric host id belongs to the database that produced it. The desktop
app lists hosts from its embedded database and names them by row id, so
when a connection is delegated to a sync server that id is resolved
against a different table, whose autoincrement sequence has no reason to
agree. The row it lands on is a different machine, and it supplies the
address, the credentials, the jump hosts and the stored host key.

#1176 and #1177 made that refuse rather than connect. Refusing is right,
but it leaves "Remote server" unusable once the ids have drifted, which
is the state the reporter was in.

syncId already names a host identically on both sides -- remote sync
relies on it, ssh_data.sync_id is unique, and the API already returns it.
It just never reached the backend: hostToSSHHost() builds its result field
by field and dropped it.

Carry it through, and resolve with it when it is present:

  resolveHostBySyncId(syncId, userId)   // translate, then reuse
    -> findHostIdBySyncId(syncId)       // this database's own row id
    -> resolveHostById(hostId, userId)  // permissions, decryption, audit

The translation is deliberately not scoped to a user -- sync_id is unique
across the table and a shared host belongs to someone else -- so access
stays with the permission check in the id-based path, which the new tests
cover.

An unknown syncId resolves to nothing rather than falling back to the
numeric id: an unknown host is precisely where guessing picks the wrong
machine. Clients that send no syncId are unchanged, address comparison
included, so an older desktop keeps its safety net instead of breaking.

* fix(homepage): make the System Overview update indicator able to fire (#1168)

The widget's "Update available" row and orange version text were unreachable,
for two independent reasons that each alone would have been enough.

It called `getVersionInfo(false)`, and `checkRemote=false` makes /version return
early with `{localVersion, status: "update_check_disabled"}` -- no GitHub fetch,
no remote version, nothing to compare. It then read `info.updateAvailable`, a
field the route does not return in either mode; the success response carries
status, localVersion, version, remoteVersion, latest_release, cached and
cache_age. `Boolean(undefined)` is false, always. The read type-checked only
because `getVersionInfo()` is declared as `Record<string, unknown>`, so a
property name that does not exist is indistinguishable from one that does.

Let the endpoint do the comparison and read `status === "requires_update"`,
which is what the dashboard stats bar and the profile panel badge already do.

The row's label was `homepage.overviewUpdate`, whose English string is "Up to
date" -- as the label of an update-available row it read "Up to date / Update
available". Nobody has seen that, because the row has never rendered; fixing
the indicator without the label would have shipped it. Give it its own key.
That leaves `homepage.overviewUpdate` unused; it is left in place rather than
removed, since it would be the natural value for an always-visible row and that
is a product decision, not part of this fix.

* fix: capture real client IP for SSH login alerts behind reverse proxy (#1169)

* fix: capture real client IP for SSH login alerts behind reverse proxy

The WebSocket terminal handler used req.socket.remoteAddress for the
"user logged in" alert message, which is the immediate TCP peer (the
reverse proxy) rather than the actual client IP forwarded via
X-Forwarded-For. This made trust-proxy config on Traefik irrelevant
since Termix never read the header for this code path.

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

* test: cover getClientIp forwarded-header and socket fallback paths

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* fix: keep already-shared hosts sharing their SSH authentication (#1179)

Sharing a host used to hand the owner's SSH authentication to the
recipient unconditionally. 2.6.1 put that behind ssh_data.share_ssh_auth,
added as NOT NULL DEFAULT 0.

Existing rows took the default, so every host shared before the upgrade
stopped supplying credentials the moment the column appeared. The snapshot
in collectProtocolSnapshots() is guarded by host.shareSshAuth, so nothing
was captured; resolveRecipientSharedHostAuthentication() then fell through
to "required" and the recipient got "No valid authentication method
provided" on a host that had worked the day before. Downgrading to 2.6.0
restored it, since that code has no such column to consult.

Backfill the flag for hosts that already appear in host_access. That is
where the previous behaviour was in effect and where the owner had already
agreed to share; hosts nobody has shared keep the new default and stay off
until their owner shares them.

Guarded by a settings key so it runs once. Without that, an owner who
turns sharing back off would have it turned on again by the next restart.

* fix: let a single credential disable 2FA again (#1180)

The disable dialog has one field, labelled "Enter TOTP code or password",
and its caller passes that value as disableTOTP(input) -- so it arrives as
`password` with `totp_code` undefined. That call has been unchanged since
v2.3.0.

2.5.1 changed the route to require both:

  if (!totp_code || (!userRecord.isOidc && !password)) -> 400

replacing `const credential = password || totp_code`. The first check has
rejected every attempt since, whatever the user typed, so nobody has been
able to turn 2FA off -- the client reports the generic "Failed to disable
2FA", which hides which check failed.

Take one credential again and try it as a TOTP code, a backup code, then
the account password. verifyTotpReauth still refuses the password itself,
so that comparison stays in the route; an OIDC user has no password hash
and reaches neither.

The backup-codes route has the same shape but no caller in the UI -- its
codes are returned when TOTP is enabled -- so it is left alone rather than
changed blind.

* fix: attach user-managed CA certificates over SFTP too (#1181)

opkssh-cert-auth.ts exports two helpers that end in the same
_applyCertToConnection: setupOPKSSHCertAuth, and setupCACertAuth for
user-managed CA-signed -cert.pub files. The file manager called the first
one twice and the second one never.

So a host whose key is paired with a CA-signed certificate authenticated
in a terminal and failed over SFTP, while OPKSSH certificates -- going
through the other helper -- worked in both. The file manager was not
missing certificate support in general; it was missing one of the two
paths into it.

The connection also never carried the certificate to begin with:
cert_public_key was not among the fields copied into resolvedCredentials,
so both places that build an SFTP connection now read it and attach it
where the private key is prepared -- the dedicated transfer session and
the main connect route.

An unusable certificate is logged and skipped rather than failing the
connection. The key alone may still be accepted, which is what happened
while this was not wired up at all, and turning that into a hard failure
would break setups that currently work.

Reported in #1160 with the call-site asymmetry already traced; the
reporter noted they could not confirm the link to their failure, having
moved off SSH CAs. The asymmetry is real either way and reproduces the
symptom exactly.

* fix: authenticate the desktop Docker console WebSocket (#1182)

The console WS opted out of the query token:

  buildOriginWsUrl({ ..., includeLocalJwt: false })

leaving it with no credential at all on the desktop. The browser
WebSocket API cannot set an Authorization header, and while Electron's
main process injects a remembered JWT cookie, it requires an exact origin
match -- the cookie belongs to the API origin (localhost:30001) while the
console connects to 127.0.0.1:30009, so nothing is attached.

The backend then closes the handshake with 1008 before it logs anything,
which is why the log has no docker-console entries while stats and logs
polling keep succeeding on the same host. The web build is unaffected: it
connects same-origin and its cookie is sent normally.

Drop the opt-out so the console carries the local JWT like the SSH
terminal does -- the same token, the same query parameter, and the
backend already reads it there.

Guacamole passes includeLocalJwt: false too, but rdp/vnc/telnet always
resolve to "remote", so that call never reaches the local branch.

* fix: use getClientIp in getRequestMeta for correct audit-log IPs (#1183)

* fix: capture real client IP for SSH login alerts behind reverse proxy

The WebSocket terminal handler used req.socket.remoteAddress for the
"user logged in" alert message, which is the immediate TCP peer (the
reverse proxy) rather than the actual client IP forwarded via
X-Forwarded-For. This made trust-proxy config on Traefik irrelevant
since Termix never read the header for this code path.

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

* test: cover getClientIp forwarded-header and socket fallback paths

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

* fix: use getClientIp in getRequestMeta for correct audit-log IPs

getRequestMeta had near-duplicate, strictly worse forwarded-header
logic: the array branch didn't split/trim, there was no socket-peer
fallback, and it returned "" instead of "unknown". Delegate to
getClientIp so the audit trail gets the same correctness as the
terminal login-alert path.

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* feat: add terminal image handoff (#1170)

* chore: sync Crowdin translations

* fix(homepage): make the System Overview update indicator able to fire (#1168)

The widget's "Update available" row and orange version text were unreachable,
for two independent reasons that each alone would have been enough.

It called `getVersionInfo(false)`, and `checkRemote=false` makes /version return
early with `{localVersion, status: "update_check_disabled"}` -- no GitHub fetch,
no remote version, nothing to compare. It then read `info.updateAvailable`, a
field the route does not return in either mode; the success response carries
status, localVersion, version, remoteVersion, latest_release, cached and
cache_age. `Boolean(undefined)` is false, always. The read type-checked only
because `getVersionInfo()` is declared as `Record<string, unknown>`, so a
property name that does not exist is indistinguishable from one that does.

Let the endpoint do the comparison and read `status === "requires_update"`,
which is what the dashboard stats bar and the profile panel badge already do.

The row's label was `homepage.overviewUpdate`, whose English string is "Up to
date" -- as the label of an update-available row it read "Up to date / Update
available". Nobody has seen that, because the row has never rendered; fixing
the indicator without the label would have shipped it. Give it its own key.
That leaves `homepage.overviewUpdate` unused; it is left in place rather than
removed, since it would be the natural value for an always-visible row and that
is a product decision, not part of this fix.

* fix: capture real client IP for SSH login alerts behind reverse proxy (#1169)

* fix: capture real client IP for SSH login alerts behind reverse proxy

The WebSocket terminal handler used req.socket.remoteAddress for the
"user logged in" alert message, which is the immediate TCP peer (the
reverse proxy) rather than the actual client IP forwarded via
X-Forwarded-For. This made trust-proxy config on Traefik irrelevant
since Termix never read the header for this code path.

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

* test: cover getClientIp forwarded-header and socket fallback paths

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* feat: add terminal image handoff

Add authenticated browser upload and clipboard image handoff for terminal agents. Normalize images through Sharp, enforce storage and request limits, preserve host-visible paths, and provide a stable three-button terminal toolbar.

* docs: document terminal image handoff deployment

---------

Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: kacperpietrzyk <105545577+kacperpietrzyk@users.noreply.github.com>
Co-authored-by: Brennan Neoh <497569+brennanneoh@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* fix(desktop): stop suppressing the update prompt, and make the version badge reachable (#1167)

* fix(desktop): stop suppressing the update prompt for users who need it

The startup update modal stored its dismissal under the local app version
rather than the remote version being offered, and the up-to-date branch
wrote that key with no user interaction at all. A user who launched while
current had their own version recorded; once the next release shipped,
`dismissedVersion === currentVersion` still held and the modal was skipped
on every launch. It reappeared only after the user had already updated --
the inverse of what it is for. Present since v2.3.0.

Key the dismissal on the offered remote version instead. The change is
backward compatible: an existing key holding 2.6.0 compares unequal against
a remote 2.6.1, so affected installs are prompted on their next launch. When
the check itself fails there is no remote version, so nothing is recorded and
no future prompt is suppressed.

That left the version badge as the only remaining signal, and it was an inert
span on both surfaces that render it -- the profile panel and the dashboard
stats bar -- even though the `getVersionInfo()` response it is built from
already carries `latest_release.html_url`. Extract the duplicated badge into
`components/version-badge.tsx` and make the update case a link to the release,
with an accessible name that says where it goes. The beta and stable cases
stay inert.

`getVersionInfo()` returned `Record<string, unknown>`, so the release URL was
unreachable without a cast; give it a `VersionInfo` type that keeps an index
signature, since `SystemOverviewWidget` reads `updateAvailable` off the same
response.

* test: cover the read that actually reaches the badge

The extracted VersionBadge is unit-tested, but the line that decides whether
it ever receives a URL -- pulling `latest_release.html_url` out of the version
response -- was duplicated at both call sites and asserted nowhere. A wrong
property there compiles (the response type keeps an index signature) and every
existing test still passes.

Give it a name, `releaseUrlFrom`, use it from both surfaces, and test it: the
happy path, a response with no release, a release with no URL, and a missing
response, since the caller's fetch can reject. Empty string is the contract the
badge reads as "nothing to link to", so it stays an inert span rather than
rendering a dead anchor.

* docs: state the index signature's real reason

The comment claimed the version endpoint carries fields beyond the typed ones,
citing `updateAvailable`. It does not -- `GET /version` returns status,
localVersion, version, remoteVersion, latest_release, cached and cache_age, and
nothing else. SystemOverviewWidget reads `updateAvailable` off it regardless,
which is why the permissive index signature has to stay, but that is a stale
read rather than an undocumented field. Say so accurately.

* Send alerts in Discord channels with Webhooks (#1158)

* feat(utils): add discord webhook sender

Add a utility to send alert embeds to Discord webhooks.

* fix(utils): validate DNS and use global fetch for outbound requests

Prevent private destination access and rely on global fetch after DNS validation.

* chore(logger): include extra context in logs

Show additional sanitized context entries for clearer diagnostics.

* feat(alerts): support discord channel type in routes and engine

Accept discord channels and route alerts to the Discord sender.

* feat(ui): add Discord option to notification channel dialog

Allow creating/editing Discord webhook channels with username/avatar.

* fix(ui/api): accept structured config payload for notification channels

Allow the client to pass structured config objects (or strings) when creating/updating channels.

* chore: sync Crowdin translations

* fix(homepage): make the System Overview update indicator able to fire (#1168)

The widget's "Update available" row and orange version text were unreachable,
for two independent reasons that each alone would have been enough.

It called `getVersionInfo(false)`, and `checkRemote=false` makes /version return
early with `{localVersion, status: "update_check_disabled"}` -- no GitHub fetch,
no remote version, nothing to compare. It then read `info.updateAvailable`, a
field the route does not return in either mode; the success response carries
status, localVersion, version, remoteVersion, latest_release, cached and
cache_age. `Boolean(undefined)` is false, always. The read type-checked only
because `getVersionInfo()` is declared as `Record<string, unknown>`, so a
property name that does not exist is indistinguishable from one that does.

Let the endpoint do the comparison and read `status === "requires_update"`,
which is what the dashboard stats bar and the profile panel badge already do.

The row's label was `homepage.overviewUpdate`, whose English string is "Up to
date" -- as the label of an update-available row it read "Up to date / Update
available". Nobody has seen that, because the row has never rendered; fixing
the indicator without the label would have shipped it. Give it its own key.
That leaves `homepage.overviewUpdate` unused; it is left in place rather than
removed, since it would be the natural value for an always-visible row and that
is a product decision, not part of this fix.

* fix: capture real client IP for SSH login alerts behind reverse proxy (#1169)

* fix: capture real client IP for SSH login alerts behind reverse proxy

The WebSocket terminal handler used req.socket.remoteAddress for the
"user logged in" alert message, which is the immediate TCP peer (the
reverse proxy) rather than the actual client IP forwarded via
X-Forwarded-For. This made trust-proxy config on Traefik irrelevant
since Termix never read the header for this code path.

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

* test: cover getClientIp forwarded-header and socket fallback paths

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* chore: add url to SENSITIVE_FIELDS for discord url

* fix: enforce SSRF protection on outbound fetches

Use `undici.fetch` with the custom DNS lookup hook to ensure the validated
DNS resolution is the one used for the connection. Fix DNS lookup/address
validation bugs and add coverage for private, public and invalid addresses,
including the resolution issue affecting Discord endpoints.

* chore: prettier format

* fix: validate all DNS addresses and close dispatcher

* fix DNS lookup validation and callback handling
* update safe outbound fetch tests
* ensure created dispatcher is properly closed

* chore: remode url from SENSITIVE_FIELDS for other logs

---------

Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: kacperpietrzyk <105545577+kacperpietrzyk@users.noreply.github.com>
Co-authored-by: Brennan Neoh <497569+brennanneoh@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* fix tmux UTF-8 path handling (#1157)

Co-authored-by: Carl <scarlettme@qq.com>

* chore: update package lock

* chore: update gitnore

* fix: [BUG] (#1049)

https://github.com/Termix-SSH/Support/issues/1049

* fix: test commitlint path fix (#1021)

* fix: SGR mouse-tracking escape codes printed as text (#1023)

* fix: quote $1 in commit-msg hook so it works from git worktrees

* fix: [BUG] could not connect to the database (#1057)

https://github.com/Termix-SSH/Support/issues/1057

* fix: [BUG] VNC connect macOS screen sharing failed (#1063)

https://github.com/Termix-SSH/Support/issues/1063

* fix: [BUG] Meta key (#1075)

https://github.com/Termix-SSH/Support/issues/1075

* fix: [BUG] Remote sync doesn't work with Termix behind nginx proxy (#1085)

https://github.com/Termix-SSH/Support/issues/1085

* fix: [BUG] webhook not working (#1080)

https://github.com/Termix-SSH/Support/issues/1080

* fix: [BUG] First server sync doesn't refresh UI (#1084)

https://github.com/Termix-SSH/Support/issues/1084

* fix: [BUG] How to enable SSL using custom certificate (#1083)

https://github.com/Termix-SSH/Support/issues/1083

* fix: [BUG] Sudo Password Auto-fill Persistance (#1098)

https://github.com/Termix-SSH/Support/issues/1098

* feat: [FEATURE] Expand Snippets Function (#1031)

https://github.com/Termix-SSH/Support/issues/1031

* feat: [FEATURE] (#1055)

https://github.com/Termix-SSH/Support/issues/1055

* feat: [FEATURE] Support for Headscale API Keys (hskey prefix) and Custom API Endpoints (#1013)

https://github.com/Termix-SSH/Support/issues/1013

* feat: [FEATURE] Allow paste on non https (#1026)

https://github.com/Termix-SSH/Support/issues/1026

* feat: be-azerty layout (#1073)

https://github.com/Termix-SSH/Support/issues/1073

* feat: Keyboard shortcuts to move between open tabs (#1069)

https://github.com/Termix-SSH/Support/issues/1069

* feat: Session Logs as a downloadable text file (#1058)

https://github.com/Termix-SSH/Support/issues/1058

* fix: persist and auto-fill saved SSH and sudo passwords

* fix: persist docker runtime selection and docker manager UI issues

* feat: Allow excluding specific mounts from disk usage metrics (#1046)

https://github.com/Termix-SSH/Support/issues/1046

* feat: Expand Snippets Function (#1031)

https://github.com/Termix-SSH/Support/issues/1031

* chore: restore the prettier baseline on dev-2.7.0 (#1185)

Five files on dev-2.7.0 do not match prettier, so `npx prettier --check .`
fails and takes lint-and-build with it — on every pull request, whatever
it changes.

Formatting only, produced by `npx prettier --write` on exactly the files
the check names. No logic touched: tsc passes for both configs, backend
148 files / 1106 tests and UI 71 files / 479 tests all pass.

* test: keep the tmux escaping test runnable on Windows (#1184)

The escaping check ran its command through /bin/sh. That binary does not
exist on Windows, and Windows is a supported platform for the desktop
app, so `npm test` fails there on a test about string quoting. CI is
ubuntu-only and would never see it.

Assert the escaped string directly, which covers the rule on every
platform, and keep the round trip through a real shell as a separate case
guarded by platform -- it is the stronger evidence where a shell exists.

* chore: drop the unreachable table probes from migrateSchema (#1186)

Eleven blocks in migrateSchema() guarded a CREATE TABLE IF NOT EXISTS
behind SELECT id FROM <table> LIMIT 1, for tables the primary bootstrap
had already created earlier in the same startup. The probe could not
throw, so the catch never ran.

Two of those unreachable copies had drifted from the definition actually
in use. sessions had lost ON DELETE CASCADE, and session_recordings still
carried user_id TEXT NOT NULL with ON DELETE CASCADE and no username --
the shape from before audit trails were made to outlive the account. They
would have taken effect had anything ever reordered startup.

Kept, because they are not the same thing:

  - blocks whose catch runs ALTER TABLE ADD COLUMN. CREATE TABLE IF NOT
    EXISTS is a no-op on a table that exists, so a database created before
    a column was added still needs the ALTER. Those probe a column, not a
    table.
  - blocks that are a table's only creation point.
  - the user_open_tabs block, which is a data migration; its SELECT is a
    precondition, not a probe.

Deletion only, no behaviour change.

* fix: repair the frontend type-check and clear the 299 errors behind it (#1189)

* fix: repair frontend type-check configuration and the errors it exposed

The root tsconfig.json is solution-style with "files": [], so the
`npx tsc --noEmit` that CI runs compiles nothing at all. Frontend types
have therefore never been checked, and 299 errors had accumulated behind
that no-op. This clears just over half of them; nothing here changes
runtime behaviour.

Configuration:
- "@/types" resolved through the "@/*" fallback to src/ui/types, which
  does not exist. Added an explicit mapping to src/types/index.ts.
- src/vite-env.d.ts sits outside the include list, so import.meta.env and
  the ?url import suffix were unknown. Added.
- src/ui/types/ held a single file, keybindings.ts, while every other
  shared type lives in src/types/. Six modules imported it as
  "@/types/keybindings" and silently resolved to nothing. Moved.

Type definitions that had fallen behind the code:
- guacamoleConfig and terminalConfig were Record<string, unknown> in
  ui-types while the editor read concrete fields off them. Both now use
  the real interfaces; GuacamoleConfig is extracted from its inline
  definition in guacamole-api.ts so the two cannot drift again.
- customThemeColors and TerminalTheme["colors"] described the same object
  with different optionality. Aligned.
- FileWindow declared its own SSHHost whose authType was "password" | "key",
  which no longer matches the eight the app supports.
- connectSSH and listSSHFiles returned Record<string, unknown>, so every
  field the callers destructured arrived as unknown.
- AxiosRequestConfig and AxiosResponse were used without being imported.

Also adds asHttpError() for the handful of catch blocks that reached into
an unknown binding, and narrows the Host | HostFolder comparator and the
RailItem union at the points where the discriminant was not carrying.

Note: dbHealthMonitor.reportDatabaseError was being called with a second
argument it does not accept, so the authenticated-or-not flag was already
being discarded at runtime. Dropped the argument to match the signature;
whether that flag was meant to gate the report is worth a separate look.

* fix: clear more of the frontend type-check baseline

Continues the previous commit; 140 errors down to 70. Three of these were
real defects rather than missing annotations.

Defects:
- DashboardTab counted active tunnels by comparing status to "CONNECTED",
  but CONNECTION_STATES.CONNECTED is "connected" and that is what the
  tunnel manager emits, so the count was always zero. Now compares against
  the constant.
- QuickActionsCard requires isAdmin and gates a block of admin-only actions
  on it, but neither call site passed it — those actions never rendered.
  Both call sites also passed isAdmin to HostStatusCard, which does not
  accept it; the prop had evidently been moved and the call sites missed.
- The host editor stores jump host ids as strings and sent them straight to
  an API typed for numbers. Backend host lookups compare against an integer
  column, which a string does not match on Postgres or MySQL. Converted.

Types brought in line with the data:
- Host and HostData were missing hasPassword, hasSudoPassword, sortOrder,
  instanceId, connectionOrigin, vaultProfileId, syncId, and the "vault"
  authType; TabContextTab was missing the "tunnel" tab, which TabContext
  already branched on.
- statsConfig and terminalConfig used inline shapes that had drifted from
  StatsConfig and TerminalConfig. Both now reference the real interfaces;
  excludedMounts, which the editor reads, was added to StatsConfig.
- downloadSSHFile, generateKeyPair and generatePublicKeyFromPrivate all
  returned Record<string, unknown> while callers read named fields.
- The Guacamole declarations were missing Keyboard.reset, Client.onfile,
  InputStream.sendAck, Status.Code and BlobReader, all already in use.
- NetworkTopologyNode/Edge could not be discriminated, though the graph
  code tells them apart by testing for source/target.

ProxyNode.type is now 4 | 5 | "http" | "socks4" | "socks5". The editor
writes the string spellings while proxy-helper.ts tests for "http" and
casts everything else to 4|5 before handing it to the socks client, so a
chained proxy reaches it as "socks5" rather than 5. Typed as what is
actually stored; reconciling the two spellings needs a migration decision
and is left alone here.

* fix: continue clearing the frontend type-check baseline

70 errors down to 44.

Dead configuration removed:
- Terminal set terminal.options.bellStyle on xterm, which dropped the
  option in v5. The host editor still exposes the setting and stores it;
  it has simply had no effect on the terminal since that upgrade. Making
  the bell work again means handling the onBell event and is left alone.
- CodeEditor passed scrollPastEnd to basicSetup, which has no such option.
- FileManager passed an id to openWindow, which assigns its own and
  discards what it is given — the component was already being rendered
  under a different id than the one the caller held.

Widgets that were registered but unreachable:
- DockerActivityWidget and SshQuickConnectWidget register under
  "docker_activity" and "ssh_quick_connect", neither of which was in
  WidgetTypeId, and both referenced config interfaces that did not exist.
  Added the ids and the two interfaces, inferred from their edit forms and
  defaultConfig.

More endpoints given their real return types: getRecentFiles,
getPinnedFiles, getFolderShortcuts (arrays, not records), downloadSSHFile,
copySSHItem, generateKeyPair, generatePublicKeyFromPrivate and getSnippets.

parseGuacamoleConfig() handles the host row carrying guacamoleConfig either
parsed or as raw JSON, which GuacamoleApp was reading fields off directly.
TerminalHostConfig was missing name, which it reads for the activity log.

* fix: continue clearing the frontend type-check baseline

44 errors down to 17.

Host and AuditLog are now type aliases rather than interfaces. An
interface has no implicit index signature, so neither could be assigned
to the `[key: string]: unknown` shapes that TerminalHostConfig,
HostMetricsTab's HostConfig and several helpers declare — eight errors
came from that alone.

More dead configuration:
- i18n passed checkWhitelist to the language detector, which no longer
  has that option; supportedLngs already covers it.
- SSHAuthDialog passed scrollPastEnd to basicSetup, same as CodeEditor.
- AudioPreview's onLoadedMetadata never fired: react-h5-audio-player
  spells the prop onLoadedMetaData.
- MarkdownRenderer destructured `inline` from code(), which react-markdown
  removed in v9, so the flag was always undefined and every inline span
  took the block branch when it happened to carry a language class. Now
  derived from whether a className is present at all.
- SnippetsPanel put a title prop on a lucide icon, which does not forward
  it; changed to aria-label so the hint is actually reachable.

updateHostConfig in TabContext replaced tab.hostConfig wholesale with the
six-field literal it receives, dropping everything else the tab held about
the host. It now merges onto the existing config.

Also: getReleasesRSS, getUserAlerts and getVersionInfo have real return
types (UpdateLog kept private copies of two of them, and VersionInfo was
missing `version`, which the endpoint sends and the panel renders);
wakeOnLan and vncCredentialId get the numeric ids they are typed for; and
the tmux formatter takes i18next's TFunction instead of a hand-written
signature it does not satisfy.

* fix: clear the last frontend type errors and make CI actually run the check

Baseline is now zero, so the check can be turned on.

`npx tsc --noEmit` — what CI ran and what `npm run type-check` was — compiles
nothing: the root tsconfig.json is solution-style with "files": [], and
plain tsc does not follow project references. Both are now `tsc -b`, which
builds tsconfig.app.json and tsconfig.node.json. Verified by planting a type
error and watching the command fail.

Last defects in this batch:
- patchOpenTab could not carry hostId, so quick-connect's "save this host
  and attach the tab to it" call was passing a field excluded from the
  type all the way down. The column exists and updateForUser spreads
  whatever it receives, so the write worked; only the types disagreed.
  Widened front to back.
- The file-comparison window opened without x, y, width or height — every
  other openWindow call passes them — and sent a `type` field WindowInstance
  does not have.
- HostEditor gated a block on authType === "warpgate", which is not one of
  the eight authType values. Unreachable, and it held only a label and a
  description. Removed.
- FileManager passed onLoadDirectory to a sidebar that neither declares nor
  reads it, and FileManagerApp passed embedded to a FileManager that has no
  such prop.
- TunnelApp's minimal Host was missing three required flags.

The remainder were assertions at boundaries that are genuinely loose: bulk
host import takes rows assembled from untyped input and validates them
server-side, and a vi.fn() whose body only throws infers never.

* feat: add drive file browser and drag-and-drop upload for RDP (#1187)

Drive redirection could already be enabled per host, but the redirected
drive lived inside guacd with no way to reach it from the browser: the
client never handled onfilesystem, so the mounted volume was writable
from Windows and invisible from Termix.

Add a file browser panel that lists the drive, downloads files, and
uploads them, plus drag-and-drop onto the display which opens the panel
and uploads into the directory currently shown. The disable-upload and
disable-download connection settings are honoured by the UI, not just
passed to guacd.

A rejected upload stops the BlobWriter without firing onerror or
oncomplete, so the error ack is watched explicitly; otherwise the
transfer would hang forever. Directory reads carry a deadline for the
same reason.

Also declares Guacamole.Object, Client.onfilesystem, BlobReader and
BlobWriter in the local type definitions, which previously omitted them.

* fix: keep the mouse working on touch-capable devices in RDP/VNC (#1190)

Reported as "mouse input broken, keyboard fine" after 2.5.1 (#1102).

2.5.1 bound Guacamole.Mouse unconditionally. 2.6.0 replaced that with a
three-way branch on touchMode, and the touch branches replace the mouse
binding instead of adding to it:

    if (touchMode === "touchscreen")      new Guacamole.Mouse.Touchscreen(el)
    else if (touchMode === "touchpad")    new Guacamole.Mouse.Touchpad(el)
    else                                  new Guacamole.Mouse(el)

The two do not overlap. Guacamole.Mouse listens for mousedown/mousemove/
mouseup; Touchscreen and Touchpad listen only for touchstart/touchmove/
touchend. So in a touch mode nothing is listening for the mouse at all.

touchMode defaults to "touchscreen" whenever navigator.maxTouchPoints > 0,
which is true of every laptop with a touchscreen — machines that are still
driven by a mouse. Those users lost the pointer entirely while the keyboard
kept working, because Guacamole.Keyboard is bound independently.

The physical pointer is now always bound and a touch emulator is layered on
top when one is selected. Extracted to bindPointerInput() so the binding is
testable; the test fails against the old branch.

Note the issue also carries a second, unrelated report where well-formed
mouse frames do reach guacd and the VNC leg ignores them. That one is not
this, and the guacd image is pinned to 1.6.0 in both 2.5.1 and 2.6.1, so it
is not an upgrade either.

* fix: deduplicate /api/folders requests to prevent intermittent folder disappearance (#1191)

* chore: sync Crowdin translations

* fix: deduplicate /api/folders requests to prevent intermittent folder disappearance

getSSHFolders() had no request deduplication while getSSHHosts() used a TTL
cache with in-flight dedupe. When loadHosts() fired multiple times during
rapid navigation between Credentials and Hosts panels, the folder response
could arrive after the hosts response, causing the sidebar tree to render
without folder metadata.

- Add foldersCache (10s TTL) in hosts-request-cache.ts
- Wrap getSSHFolders() API call in getCachedSSHFolders()
- Invalidate folders cache on renameFolder, updateFolderMetadata,
  deleteAllHostsInFolder, and renameCredentialFolder
- Include foldersCache in invalidateHostsAndStatusCaches()

Closes Termix-SSH/Support#1103

Signed-off-by: RawNuke <67506722+RawNuke@users.noreply.github.com>

---------

Signed-off-by: RawNuke <67506722+RawNuke@users.noreply.github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>

* chore(deps): bump undici from 8.9.0 to 8.10.0 in the prod-minor-updates group (#1195)

* chore: sync Crowdin translations

* chore(deps): bump undici in the prod-minor-updates group

Bumps the prod-minor-updates group with 1 update: [undici](https://github.com/nodejs/undici).


Updates `undici` from 8.9.0 to 8.10.0
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.9.0...v8.10.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 8.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
...

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat: proxmox metrics integration

* feat: add folder select to the host multi select feature

* feat: implement context aware terminal toolbar with quick links, host info, image pasting, etc

* feat: made toolbar open file manager at path

* fix: delete folder route not invalidating host list cache

* fix: match host list icons with tab bar iconfix

* fix: change sidebar reset button icon to seperate against fullscreen button

* feat: unify connection system and add connection logs to guacd hosts

* fix: make mobile terminal scrollback match xterm wheel behavior (#1198)

* fix: route mobile terminal scrolling through xterm viewport

* docs: document mobile terminal touch scrolling

* chore: add a note to not place files in docs

* chore: remove touch imput from docs

* feat: improve snippet system with variable snippets and collapse settings

* feat: new fleet system with snippet, packages, files, and inventory features

* fix: command pallete not loading new activity and made enter load first item

* feat: add subhost from parent host organization feature

* feat: add workspaces feature to save tab layout

* perf: greatly improved performance across metrics polling and host management for enterprise users

* feat: add a onboarding system with a new interface simplicity system

* feat: finalize the multi dialect database system

* fix: bind trusted MFA devices to client installs (#1202)

* fix: merge OIDC group claims across sources (#1203)

* fix: allow disabling SSH keepalives (#1204)

* fix: distinguish reachable and available hosts (#1206)

* fix: throttle session activity persistence (#1207)

* fix: preserve saved RDP connection settings (#1208)

* fix: authenticate tunnel status stream (#1209)

* fix: select quick-created credentials (#1210)

* fix: stagger initial metrics collection (#1211)

* fix: stagger initial metrics collection

* fix: admit reachable hosts to initial metrics

* fix: prevent long host names shifting dashboard metrics (#1205)

* feat: add global touch input settings (#1201)

Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>

* fix: keep host list row sizing stable (#1213)

* fix(guacamole): correct Windows key mapping (#1216)

* fix: normalize OIDC discovery issuer URLs (#1218)

* fix: prompt for RDP domain credentials (#1212)

* fix: route status checks by connection origin (#1214)

* fix: restore desktop Tailscale configuration (#1215)

* fix(docker): restore Node 24 for ssh2 native crypto (#1217)

* feat: added new automations feature with events, channels, and steps

* feat: allowed some tabs in the app rail to be opened as its own tab or in a new right sidebar

* feat: expand onboarding process with more customization/features

* feat: initial implementation of the termix ai feature

* chore: run linter

* fix: issue #424 (#424)

https://github.com/Termix-SSH/Support/issues/424

* fix: Not working without internet connection. Missing OPKSSH binary in pre-built image. (#1133)

https://github.com/Termix-SSH/Support/issues/1133

* fix: SQLite forceSave on telemetry writes causes periodic SSH terminal stalls in 2.6.x (#1109)

https://github.com/Termix-SSH/Support/issues/1109

* feat: How to enable SSL using custom certificate (#1083)

https://github.com/Termix-SSH/Support/issues/1083

* fix: show profile API key after creation (#1221)

* feat: add trusted proxy authentication (#1222)

* fix: clarify SSH agent authentication (#1224)

* feat: add first-class split screen tabs (#1226)

* feat: add split tab data model

* feat: make split screens top-level tabs

* feat: persist and manage split layouts

* feat: launch native RDP on Windows desktop (#1223)

* feat: launch native RDP on Windows

* style: format native RDP launcher

* feat: enhance custom disk and network metrics (#1220)

* feat: enhance host disk and network metrics

* fix: align enhanced metrics types

* fix: preserve Proxmox guest identity on edit (#1219)

* fix: preserve Proxmox guest identity on edit

* fix: type Proxmox guest source metadata

* chore: dead-code cleanup and small refactors (#1225)

* chore: remove dead code and unused exports

* chore: remove unused api client functions

* chore: remove unused backend helpers

* refactor: extract getErrorMessage helper for repeated error extraction

* refactor: unify error message extraction across backend with getErrorMessage

* refactor: unify error message extraction in frontend with getErrorMessage

* refactor: merge duplicate imports from the same module

* refactor: use Array.includes in TabBar

* chore: drop biome, keep prettier as the single formatter

* style: apply prettier formatting to refactored files

* fix: close active tab with Ctrl+W on Windows

* fix: make tray Quit terminate the desktop app

* feat: verify host transfer integrity

* fix: reuse transfer sessions during verification

* feat: select the fastest host transfer route

* feat: tune host transfers adaptively

* feat: adapt background polling to activity (#1233)

* feat: adapt background polling to activity

* feat: extend adaptive polling coverage

* feat: make polling cost and network aware (#1234)

* feat: make repeat navigation feel instant (#1235)

* feat: make file operations feel immediate (#1236)

* feat: preload likely user actions (#1237)

* feat: preload likely file previews

* feat: preload likely host tools

* feat: preload likely file viewers

* fix: replace stale terminal input listeners

* feat: add links to docs for all new features

* chore: update readme

* fix: warn before discarding host changes (#1229)

* feat: learn local host action preferences (#1238)

* feat(terminal-toolbar): add bounded movable desktop toolbar (#1239)

* feat: add local adaptive decision engine (#1240)

* feat: adapt speculative resource usage (#1241)

* feat: persist adaptive transfer profiles (#1242)

* Fix .preferred_username when using LDAP login. (#1243)

* chore: sync Crowdin translations

* Fix .preferred_username when using LDAP login. Strips internal LDAP prefix from username.

---------

Co-authored-by: LukeGus <bugattiguy527@gmail.com>

* feat: learn direct transfer routes (#1244)

* feat: learn speculative preload usefulness (#1245)

* fix: - Adjusting the SSH Authentication from Vault to something else fails (#1152)

https://github.com/Termix-SSH/Support/issues/1152

* fix: terminal graphical display, special characters inserted, distorted - `midnight comma... (#1145)

https://github.com/Termix-SSH/Support/issues/1145

* feat: single click on host in list opens session - should be only on double click (#1146)

https://github.com/Termix-SSH/Support/issues/1146

* feat: Terminal: custom font/ font selection/ how-to for adding a font - `MesloLGS NF` (#1140)

https://github.com/Termix-SSH/Support/issues/1140

* fix: revert host single click to open session, make double click an option (#1146)

Single click opens a session again by default. The old double click
behavior can be turned on in Customize Sidebar.

* chore: drop prettier check from beta release workflow, run formatter

* chore: patch dependabot vulnerabilities via npm overrides

* fix: reset adaptive resource state between tests to stop cross-test leaks

* feat: replace terminal toolbar density popover with a native select

* fix: pin hardwareConcurrency in adaptive budget tests so CI cores don't change the tier

* fix: allow dylib files in mac universal arch rules so mas build packages sharp

* feat: add file manager trash (#1250)

* feat: add inheritable connection defaults (#1246)

* feat: add desktop local terminal (#1247)

* feat: add interactive terminal macros (#1248)

* feat: add adaptive SSH local echo (#1249)

* fix: sync desktop host changes immediately (#1252)

* fix: route desktop sharing through synced server (#1253)

* Fix terminal image uploads and add safe diagnostics (#1254)

* feat: add configurable terminal image storage backends

* feat: add admin image storage settings

* fix: preserve native clipboard PNG uploads

* fix: quote terminal image paths safely

* docs: record image storage security remediation plan

* fix: close remote image SFTP channels

* fix: restrict remote image SFTP permissions

* fix: bound remote image SFTP writes

* fix: add best effort remote image retention

* fix: cap normalized image output size

* fix: bound concurrent image processing

* fix: fail closed on local image inspection errors

* test: cover fail closed image storage and atomic settings

* fix: enforce remote image quota and upload admission

* fix: serialize remote quota and verify existing paths

* fix: use synchronous sqlite settings transaction

* fix: keep settings transactions portable across dialects

* fix: bound image processing admission queue

* fix: serialize remote image quota across processes

* fix: recover stale remote image locks safely

* fix: preserve remote storage errors during unlock

* fix: fail closed when stale lock removal fails

* fix: harden image upload resource and storage cleanup

* fix: bound SFTP operations and lock lifetime

* fix: bound SFTP acquisition and cleanup callbacks

* fix: close late SFTP channels and test cleanup stalls

* fix: preserve SFTP inspection client context

* feat: add image upload source metadata

* fix: expose image upload metadata in logs

* chore: exclude internal plan from pull request

* style: apply prettier formatting

---------

Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>

* fix: batch of security hardening fixes (#1255)

* fix: bind desktop auto-session loopback check to the TCP peer address

* fix: escape HTML entities in Vault OIDC callback responses

* fix: route homepage ping and rss through the SSRF-safe outbound fetch

* fix: scope tunnel status endpoints to hosts the caller can access

* chore: update release notes

* chore: update release notes to write more about the ai integration

* fix: unbreak windows and macos electron builds after node-pty

Install Spectre-mitigated MSVC libs on the Windows runner and cover
node-pty's spawn-helper in the macOS universal arch rules.

* fix: rework connection defaults ui into a dialog and add missing i18n keys

* fix: rework macros panel with i18n, plain text matching, and list layout

* feat: add docs links for trash, connection defaults, and local echo

* fix: make image storage and trash tests pass on windows

* fix: stop docs links squeezing sidebar panel headers

* fix: put automations docs link back on the tabs row

* fix(desktop): keep Linux credential storage working on unrecognised desktops (#1261)

Chromium resolves safeStorage's backend from XDG_CURRENT_DESKTOP and falls
back to the basic_text store for any desktop it has no mapping for, which
covers every wlroots-style compositor (Hyprland, sway, niri, river).
isEncryptionAvailable() reports false for that store, so saveRemoteSyncJwt
refused every write and the OIDC sign-in it was storing appeared to succeed.
The sync engine then found no JWT and reported the session as expired, which
sent users looking at their OIDC provider for a fault that was never there.

Name the libsecret backend explicitly on those desktops. They run an ordinary
Secret Service, so that is enough to make encryption available again. KWallet
desktops keep their auto-detected backend, an explicit --password-store still
wins, and no stored secret can be orphaned by the switch because
isEncryptionAvailable() gated every write that would have created one.

Also stop discarding the {success: false} the main process returns when it
cannot store a credential: on a machine with no Secret Service at all, the
sign-in now says so instead of silently completing.

Co-authored-by: alexandre-vl <rafaelsenchais@gmail.com>

* chore: update release notes

* chore: update release notes

* fix(file-manager): widen trash dialog so names and paths are not cut off

* fix(sidebar): stop hover action tray overlapping the row below it

* fix(hosts): make real status colors toggle actually apply

* feat(local-terminal): add rail button and fix hardcoded tab label

* chore: update release notes

* fix(ai): hide assistant everywhere when admin disables it globally

* fix(automations): fix concurrency race, wire docker and internal event triggers

Claim the in-flight slot in the same tick it is checked, poll container
state for docker_event triggers, emit the internal events, apply the
schedule time zone, and expose the concurrency policy in the editor.

* fix(sidebar): rework host and credential drag-to-reorder

Adds a lock toggle in the sort menu and fixes reorder positioning,
cross-folder drops, and the duplicate drop indicator.

* chore(sidebar): drop unused sortKey prop from host and credential trees

* fix(sidebar): fix row height in click tray mode so status stripes stop overlapping

* fix(onboarding): remove add-first-host step that closed onboarding mid-flow

* fix(release): upload release notes so Mac App Store review submission stops failing

* chore: sync Crowdin translations for 2.7.0

---------

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: RawNuke <67506722+RawNuke@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: kacperpietrzyk <105545577+kacperpietrzyk@users.noreply.github.com>
Co-authored-by: Brennan Neoh <497569+brennanneoh@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: T3rM1nAt0-R <niraj.sangani91@gmail.com>
Co-authored-by: Horziox <horziox.dev@gmail.com>
Co-authored-by: William Shi <184219650@qq.com>
Co-authored-by: Carl <scarlettme@qq.com>
Co-authored-by: Raw_Nuke <67506722+RawNuke@users.noreply.github.com>
Co-authored-by: njz-cvm <njz@cvm.com>
Co-authored-by: Alexandre VARGAS <alexandre.vargas.lopez@gmail.com>
Co-authored-by: alexandre-vl <rafaelsenchais@gmail.com>
This commit is contained in:
Luke Gustafson
2026-08-19 14:12:06 -05:00
committed by GitHub
co-authored by dependabot[bot] ZacharyZcR kacperpietrzyk Brennan Neoh Claude Sonnet 5 T3rM1nAt0-R Horziox William Shi Carl Raw_Nuke njz-cvm Alexandre VARGAS alexandre-vl
parent 5021ccf3e2
commit 7ae1648c25
837 changed files with 387941 additions and 15831 deletions
+16 -14
View File
@@ -13,11 +13,16 @@ import { Auth } from "@/auth/Auth.tsx";
import { Toaster } from "@/components/sonner.tsx";
import { dbHealthMonitor } from "@/lib/db-health-monitor.ts";
import { useTranslation } from "react-i18next";
import { RefreshCw } from "lucide-react";
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
export type FullScreenAppPhase = "loading" | "not-found" | "ready";
interface FullScreenAppWrapperProps {
hostId?: string;
children: (hostConfig: SSHHost | null, loading: boolean) => React.ReactNode;
children: (
hostConfig: SSHHost | null,
phase: FullScreenAppPhase,
) => React.ReactNode;
}
export const FullScreenAppWrapper: React.FC<FullScreenAppWrapperProps> = ({
@@ -146,17 +151,8 @@ export const FullScreenAppWrapper: React.FC<FullScreenAppWrapperProps> = ({
if (authLoading) {
return (
<div
className="w-full h-screen overflow-hidden flex flex-col items-center justify-center gap-4"
style={{ backgroundColor: "var(--bg-base)" }}
>
<RefreshCw
className="size-8 animate-spin"
style={{ color: "var(--foreground)" }}
/>
<p className="text-sm" style={{ color: "var(--foreground-secondary)" }}>
{t("common.loading")}
</p>
<div className="relative w-full h-screen overflow-hidden">
<ConnectionScreen status="connecting" message={t("common.loading")} />
</div>
);
}
@@ -185,6 +181,12 @@ export const FullScreenAppWrapper: React.FC<FullScreenAppWrapperProps> = ({
);
}
const phase: FullScreenAppPhase = loading
? "loading"
: !hostConfig && hostId
? "not-found"
: "ready";
return (
<SidebarProvider>
<TabProvider>
@@ -193,7 +195,7 @@ export const FullScreenAppWrapper: React.FC<FullScreenAppWrapperProps> = ({
className="w-full h-screen overflow-hidden"
style={{ backgroundColor: "var(--bg-base)" }}
>
{children(hostConfig, loading)}
{children(hostConfig, phase)}
</div>
</CommandHistoryProvider>
</TabProvider>
+23
View File
@@ -0,0 +1,23 @@
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
interface AiMessageProps {
role: "user" | "assistant";
content: string;
}
export function AiMessage({ role, content }: AiMessageProps) {
if (role === "user") {
return (
<div className="rounded-none border border-border bg-muted px-3 py-2 text-sm whitespace-pre-wrap break-words">
{content}
</div>
);
}
return (
<div className="text-sm leading-relaxed [&_a]:underline [&_code]:font-mono [&_code]:text-xs [&_li]:my-0.5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_p]:my-2 [&_pre]:overflow-x-auto [&_pre]:rounded-none [&_pre]:border [&_pre]:border-border [&_pre]:bg-muted [&_pre]:p-2 [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
);
}
+533
View File
@@ -0,0 +1,533 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Loader2, Send, Settings2, Sparkles, Square } from "lucide-react";
import { Button } from "@/components/button";
import { Textarea } from "@/components/textarea";
import { saveUserPreferences } from "@/main-axios";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
import {
getAiConversation,
getAiProviders,
getAiStatus,
type AiProposal,
type AiProvider,
} from "@/api/ai-api";
import { AiMessage } from "./AiMessage";
import { AiProviderSettings } from "./AiProviderSettings";
import { AiToolCall } from "./AiToolCall";
import { ProposalCard } from "./ProposalCard";
import { useAiStream, type ToolActivity } from "./use-ai-stream";
import {
activeMentionQuery,
useMentions,
type MentionItem,
} from "./useMentions";
import { mentionLabel } from "./labels";
interface HistoryEntry {
role: "user" | "assistant";
content: string;
}
/** One entry in the rendered conversation, in the order it happened. */
type TimelineItem =
| {
kind: "message";
key: string;
role: "user" | "assistant";
content: string;
}
| { kind: "tool"; key: string; tool: ToolActivity }
| { kind: "proposal"; key: string; proposal: AiProposal };
export function AiPanel({ activeTab }: { activeTab?: string | null }) {
const { t } = useTranslation();
const { state, send, stop } = useAiStream();
const [providers, setProviders] = useState<AiProvider[]>([]);
const [providerId, setProviderId] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [showSettings, setShowSettings] = useState(false);
const [input, setInput] = useState("");
const [history, setHistory] = useState<HistoryEntry[]>([]);
const [resolvedProposals, setResolvedProposals] = useState<
Record<number, { status: "applied" | "rejected"; resultSummary?: string }>
>({});
const scrollRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [mention, setMention] = useState<{
query: string;
start: number;
} | null>(null);
const [mentionIndex, setMentionIndex] = useState(0);
const { search: searchMentions } = useMentions(Boolean(providerId));
const mentionMatches = mention ? searchMentions(mention.query) : [];
const updateMentionQuery = (value: string, caret: number) => {
const next = activeMentionQuery(value, caret);
setMention(next);
setMentionIndex(0);
};
/**
* Replaces the "@partial" under the caret with the chosen name. The mention
* is plain text: the assistant still has to call a read tool to look the
* item up, so nothing is attached to the prompt behind the user's back.
*/
const insertMention = (item: MentionItem) => {
if (!mention) return;
const caret = textareaRef.current?.selectionStart ?? input.length;
const before = input.slice(0, mention.start);
const after = input.slice(caret);
const inserted = `@${item.label} `;
setInput(`${before}${inserted}${after}`);
setMention(null);
requestAnimationFrame(() => {
const position = before.length + inserted.length;
textareaRef.current?.focus();
textareaRef.current?.setSelectionRange(position, position);
});
};
const loadProviders = useCallback(async (selectId?: number) => {
try {
const list = await getAiProviders();
setProviders(list);
setProviderId((current) => {
// A provider that was just added becomes the active one, so there is
// no second step between creating it and being able to use it.
if (selectId && list.some((entry) => entry.id === selectId)) {
return selectId;
}
// Fall back to the first if the current selection has gone away.
if (current && list.some((entry) => entry.id === current)) {
return current;
}
return list[0]?.id ?? null;
});
return list;
} catch {
setProviders([]);
return [];
}
}, []);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const status = await getAiStatus();
if (cancelled) return;
// Opening the panel is consent to use it. Without this every write
// 403s through the gate, which surfaces as a bare permission error
// rather than anything the user can act on.
if (status.globallyEnabled && !status.enabled) {
await saveUserPreferences({ aiAssistantEnabled: true });
}
if (!status.globallyEnabled) return;
const list = await loadProviders();
// With nothing configured there is nothing to chat with, so go
// straight to the form instead of showing a card that just vanishes.
if (!cancelled && list.length === 0) setShowSettings(true);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [loadProviders]);
useEffect(() => {
scrollRef.current?.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: "smooth",
});
}, [state.assistantText, state.tools.length, history.length]);
const reloadHistory = useCallback(
async (conversationId: number | null, reply: string) => {
// Reload from the server so the transcript is the stored one rather
// than a client-side reconstruction.
if (conversationId) {
try {
const loaded = await getAiConversation(conversationId);
setHistory(
loaded.messages
.filter((entry) => entry.role !== "tool")
.map((entry) => ({
role: entry.role as "user" | "assistant",
content: entry.content,
})),
);
return;
} catch {
// Fall through to keeping the streamed copy.
}
}
// No stored copy to fall back on, so the streamed reply is appended
// locally. Without this it would vanish when the next turn resets the
// stream state.
if (reply) {
setHistory((prev) => [...prev, { role: "assistant", content: reply }]);
}
},
[],
);
async function handleSend() {
const message = input.trim();
if (!message || !providerId || state.streaming) return;
setInput("");
setHistory((prev) => [...prev, { role: "user", content: message }]);
await send({
message,
providerId,
conversationId: state.conversationId,
activeTab,
onComplete: reloadHistory,
});
}
/**
* Records the outcome on the card and tells the assistant what happened.
*
* Without the follow-up the run just stops: the assistant proposed a
* command, the user approved it, and the model never learns it produced
* output, so it sits there as if the tool were still running.
*/
function handleProposalResolved(
id: number,
status: "applied" | "rejected",
resultSummary?: string,
) {
setResolvedProposals((prev) => ({
...prev,
[id]: { status, resultSummary },
}));
if (!providerId || state.streaming) return;
const proposal = state.proposals.find((entry) => entry.id === id);
const label = proposal?.summary ?? proposal?.kind ?? `proposal ${id}`;
const followUp =
status === "applied"
? resultSummary
? `I approved "${label}". Result:\n\n${resultSummary}`
: `I approved "${label}". It completed.`
: `I rejected "${label}". Do not retry it unless I ask.`;
setHistory((prev) => [...prev, { role: "user", content: followUp }]);
void send({
message: followUp,
providerId,
conversationId: state.conversationId,
activeTab,
onComplete: reloadHistory,
});
}
const proposals: AiProposal[] = state.proposals.map((proposal) => {
const resolved = resolvedProposals[proposal.id];
if (!resolved) return proposal;
return {
...proposal,
status: resolved.status,
resultSummary: resolved.resultSummary ?? proposal.resultSummary,
};
});
/**
* The reply exists in two places once a turn finishes: the streamed text in
* `state`, and the stored copy that `onComplete` reloads into `history`.
* Whichever arrives is shown exactly once, and the stored copy wins because
* it sits in the right place in the timeline.
*/
const last = history[history.length - 1];
const historyEndsWithThisReply =
state.assistantText.length > 0 &&
last?.role === "assistant" &&
last.content === state.assistantText;
const streamingReply = historyEndsWithThisReply ? "" : state.assistantText;
/**
* One ordered list rather than three stacked blocks.
*
* Tool calls happen before the reply that describes them, but once a turn
* ends its reply moves into `history`, which used to render above a separate
* tools block. The tool call appeared to jump below the answer it produced.
* Splicing the current turn's activity in ahead of its own reply keeps the
* order stable across that handover.
*/
const timeline: TimelineItem[] = [];
const trailingReply = historyEndsWithThisReply ? history.length - 1 : -1;
history.forEach((entry, index) => {
if (index === trailingReply) return;
timeline.push({
kind: "message",
key: `history-${index}`,
role: entry.role,
content: entry.content,
});
});
for (const tool of state.tools) {
timeline.push({ kind: "tool", key: tool.id, tool });
}
if (trailingReply >= 0) {
timeline.push({
kind: "message",
key: `history-${trailingReply}`,
role: "assistant",
content: history[trailingReply].content,
});
} else if (streamingReply) {
timeline.push({
kind: "message",
key: "streaming",
role: "assistant",
content: streamingReply,
});
}
for (const proposal of proposals) {
timeline.push({
kind: "proposal",
key: `proposal-${proposal.id}`,
proposal,
});
}
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="animate-spin text-muted-foreground" size={20} />
</div>
);
}
return (
<div className="flex h-full flex-col">
{/*
The panel also lives in the narrow sidebar, so the title and the
provider picker get their own rows rather than competing for one line.
*/}
<div className="border-b border-border">
<div className="flex items-center gap-2 px-3 py-2">
<Sparkles size={16} className="shrink-0" />
<span className="truncate text-sm font-medium">{t("ai.title")}</span>
<a
href="https://docs.termix.site/features/ai/overview"
target="_blank"
rel="noreferrer"
className="ml-auto shrink-0 text-[10px] text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
<Button
size="sm"
variant="ghost"
className="shrink-0"
onClick={() => setShowSettings((value) => !value)}
aria-label={t("ai.providerSettings")}
>
<Settings2 size={14} />
</Button>
</div>
{providers.length > 0 && (
<div className="flex items-center gap-2 px-3 pb-2">
<Select
value={providerId ? String(providerId) : undefined}
onValueChange={(value) => setProviderId(Number(value))}
>
<SelectTrigger className="h-7 min-w-0 flex-1 rounded-none text-xs">
<SelectValue placeholder={t("ai.selectProvider")} />
</SelectTrigger>
<SelectContent>
{providers.map((provider) => (
<SelectItem key={provider.id} value={String(provider.id)}>
{provider.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
{showSettings && (
<div className="border-b border-border p-3">
<AiProviderSettings
providers={providers}
onChanged={loadProviders}
// Adding the first provider is the reason the form opened, so it
// closes once that is done and the chat becomes usable.
onAdded={() => setShowSettings(false)}
/>
</div>
)}
<div ref={scrollRef} className="flex-1 space-y-3 overflow-y-auto p-3">
{timeline.map((item) => {
if (item.kind === "message") {
return (
<AiMessage
key={item.key}
role={item.role}
content={item.content}
/>
);
}
if (item.kind === "tool") {
return (
<AiToolCall
key={item.key}
tool={item.tool}
streaming={state.streaming}
/>
);
}
return (
<ProposalCard
key={item.key}
proposal={item.proposal}
onResolved={handleProposalResolved}
/>
);
})}
{state.error && (
<div className="rounded-none border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{state.error}
</div>
)}
</div>
<div className="relative border-t border-border p-3">
{mentionMatches.length > 0 && (
<div className="absolute bottom-full left-3 right-3 z-10 max-h-56 overflow-y-auto border border-border bg-popover shadow-md">
{mentionMatches.map((item, index) => (
<button
key={`${item.kind}-${item.id}`}
type="button"
className={`flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs ${
index === mentionIndex ? "bg-muted" : ""
}`}
onMouseDown={(event) => {
// mousedown, so the textarea does not lose the caret first.
event.preventDefault();
insertMention(item);
}}
>
<span className="shrink-0 text-[10px] uppercase text-muted-foreground">
{mentionLabel(item.kind)}
</span>
<span className="truncate font-medium">{item.label}</span>
{item.detail && (
<span className="ml-auto shrink-0 truncate text-[10px] text-muted-foreground">
{item.detail}
</span>
)}
</button>
))}
</div>
)}
<Textarea
ref={textareaRef}
className="min-h-[64px] resize-none rounded-none text-sm"
value={input}
onChange={(event) => {
setInput(event.target.value);
updateMentionQuery(
event.target.value,
event.target.selectionStart ?? 0,
);
}}
onKeyDown={(event) => {
if (mentionMatches.length > 0) {
if (event.key === "ArrowDown") {
event.preventDefault();
setMentionIndex((i) => (i + 1) % mentionMatches.length);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setMentionIndex(
(i) =>
(i - 1 + mentionMatches.length) % mentionMatches.length,
);
return;
}
if (event.key === "Enter" || event.key === "Tab") {
event.preventDefault();
insertMention(mentionMatches[mentionIndex]);
return;
}
if (event.key === "Escape") {
event.preventDefault();
setMention(null);
return;
}
}
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void handleSend();
}
}}
placeholder={t("ai.inputPlaceholder")}
disabled={!providerId}
/>
{/*
The hint gets its own line and wraps: sharing a row with the send
button left it truncated to an ellipsis in the sidebar, where the
panel is at its narrowest.
*/}
<p className="mt-2 text-[11px] leading-snug text-muted-foreground">
{t("ai.attachHint")}
</p>
<div className="mt-1.5 flex items-center justify-end gap-2">
{state.streaming ? (
<Button size="sm" variant="outline" onClick={stop}>
<Square size={14} />
{t("ai.stop")}
</Button>
) : (
<Button
size="sm"
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
disabled={!input.trim() || !providerId}
onClick={() => void handleSend()}
>
<Send size={14} />
{t("ai.send")}
</Button>
)}
</div>
</div>
</div>
);
}
+353
View File
@@ -0,0 +1,353 @@
import { getErrorMessage } from "../../lib/error-message.js";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Loader2, Plus, RefreshCw, Trash2 } from "lucide-react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Label } from "@/components/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
import {
createAiProvider,
deleteAiProvider,
probeAiModels,
type AiProvider,
type AiProviderType,
} from "@/api/ai-api";
const PROVIDER_TYPES: Array<{
value: AiProviderType;
labelKey: string;
needsBaseUrl: boolean;
needsApiKey: boolean;
defaultBaseUrl?: string;
}> = [
{
value: "ollama",
labelKey: "ai.providerOllama",
needsBaseUrl: true,
needsApiKey: false,
defaultBaseUrl: "http://localhost:11434",
},
{
value: "anthropic",
labelKey: "ai.providerAnthropic",
needsBaseUrl: false,
needsApiKey: true,
},
{
value: "openai",
labelKey: "ai.providerOpenai",
needsBaseUrl: false,
needsApiKey: true,
},
{
value: "gemini",
labelKey: "ai.providerGemini",
needsBaseUrl: false,
needsApiKey: true,
},
{
value: "openai_compatible",
labelKey: "ai.providerOpenaiCompatible",
needsBaseUrl: true,
needsApiKey: false,
},
];
interface AiProviderSettingsProps {
providers: AiProvider[];
/** selectId names a provider that should become the active one. */
onChanged: (selectId?: number) => void;
onAdded?: () => void;
}
export function AiProviderSettings({
providers,
onChanged,
onAdded,
}: AiProviderSettingsProps) {
const { t } = useTranslation();
const [adding, setAdding] = useState(false);
const [saving, setSaving] = useState(false);
const [providerType, setProviderType] = useState<AiProviderType>("ollama");
const [label, setLabel] = useState("");
const [baseUrl, setBaseUrl] = useState("http://localhost:11434");
const [apiKey, setApiKey] = useState("");
const [defaultModel, setDefaultModel] = useState("");
const [models, setModels] = useState<string[]>([]);
const [detecting, setDetecting] = useState(false);
const [detectWarning, setDetectWarning] = useState<string | null>(null);
const [customModel, setCustomModel] = useState(false);
const spec = PROVIDER_TYPES.find((entry) => entry.value === providerType)!;
useEffect(() => {
setBaseUrl(spec.defaultBaseUrl ?? "");
setApiKey("");
setModels([]);
setDefaultModel("");
setCustomModel(false);
setDetectWarning(null);
}, [providerType, spec.defaultBaseUrl]);
/**
* Fetches the provider's own model list so nobody has to go and look model
* names up. Falls back to a curated list when the endpoint is unreachable,
* and a free-text field is always available for anything not listed.
*/
const detectModels = useCallback(async () => {
setDetecting(true);
setDetectWarning(null);
try {
const result = await probeAiModels({
providerType,
baseUrl: baseUrl.trim() || null,
apiKey: apiKey.trim() || null,
});
setModels(result.models);
if (result.source === "fallback") {
setDetectWarning(t("ai.modelDetectFailed"));
}
// Pick the first suggestion so the field is never left empty.
setDefaultModel((current) => current || result.models[0] || "");
} catch {
setDetectWarning(t("ai.modelDetectFailed"));
} finally {
setDetecting(false);
}
}, [providerType, baseUrl, apiKey, t]);
// Detect as soon as the provider has enough detail to be reachable.
useEffect(() => {
if (!adding) return;
const ready = spec.needsApiKey ? apiKey.trim().length > 0 : true;
if (!ready) return;
const timer = setTimeout(() => void detectModels(), 400);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [adding, providerType, baseUrl, apiKey]);
async function handleAdd() {
if (!label.trim()) {
toast.error(t("ai.labelRequired"));
return;
}
setSaving(true);
try {
const created = await createAiProvider({
providerType,
label: label.trim(),
baseUrl: baseUrl.trim() || null,
apiKey: apiKey.trim() || null,
defaultModel: defaultModel.trim() || null,
});
setAdding(false);
setLabel("");
setApiKey("");
setDefaultModel("");
onChanged(created.id);
onAdded?.();
} catch (error) {
toast.error(getErrorMessage(error, t("ai.providerSaveFailed")));
} finally {
setSaving(false);
}
}
async function handleDelete(id: number) {
try {
await deleteAiProvider(id);
onChanged();
} catch (error) {
toast.error(getErrorMessage(error, t("ai.providerDeleteFailed")));
}
}
return (
<div className="space-y-3">
{providers.map((provider) => (
<div
key={provider.id}
className="flex items-center justify-between gap-2 rounded-none border border-border bg-muted px-3 py-2"
>
<div className="min-w-0">
<div className="truncate text-sm font-medium">{provider.label}</div>
<div className="truncate text-xs text-muted-foreground">
{provider.providerType}
{provider.baseUrl ? ` · ${provider.baseUrl}` : ""}
{provider.apiKeyPrefix ? ` · ${provider.apiKeyPrefix}` : ""}
</div>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => handleDelete(provider.id)}
aria-label={t("ai.removeProvider")}
>
<Trash2 size={14} />
</Button>
</div>
))}
{!adding && (
<Button size="sm" variant="outline" onClick={() => setAdding(true)}>
<Plus size={14} />
{t("ai.addProvider")}
</Button>
)}
{adding && (
<div className="space-y-3 rounded-none border border-border p-3">
<div className="space-y-1.5">
<Label>{t("ai.providerType")}</Label>
<Select
value={providerType}
onValueChange={(value) =>
setProviderType(value as AiProviderType)
}
>
<SelectTrigger className="rounded-none">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROVIDER_TYPES.map((entry) => (
<SelectItem key={entry.value} value={entry.value}>
{t(entry.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>{t("ai.providerLabel")}</Label>
<Input
className="rounded-none"
value={label}
onChange={(event) => setLabel(event.target.value)}
placeholder={t("ai.providerLabelPlaceholder")}
/>
</div>
{spec.needsBaseUrl && (
<div className="space-y-1.5">
<Label>{t("ai.baseUrl")}</Label>
<Input
className="rounded-none"
value={baseUrl}
onChange={(event) => setBaseUrl(event.target.value)}
placeholder="http://localhost:11434"
/>
<p className="text-xs text-muted-foreground">
{t("ai.privateEndpointHint")}
</p>
</div>
)}
{(spec.needsApiKey || providerType === "openai_compatible") && (
<div className="space-y-1.5">
<Label>{t("ai.apiKey")}</Label>
<Input
className="rounded-none"
type="password"
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
autoComplete="off"
/>
</div>
)}
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<Label className="min-w-0 flex-1">{t("ai.defaultModel")}</Label>
<button
type="button"
className="flex shrink-0 items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground"
onClick={() => void detectModels()}
disabled={detecting}
>
{detecting ? (
<Loader2 size={11} className="animate-spin" />
) : (
<RefreshCw size={11} />
)}
{t("ai.modelRefresh")}
</button>
</div>
{models.length > 0 && !customModel ? (
<Select
value={defaultModel || undefined}
onValueChange={(value) => {
if (value === "__custom__") {
setCustomModel(true);
setDefaultModel("");
return;
}
setDefaultModel(value);
}}
>
<SelectTrigger className="rounded-none">
<SelectValue placeholder={t("ai.modelPlaceholder")} />
</SelectTrigger>
<SelectContent>
{models.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
{/* Anything the provider did not list is still reachable. */}
<SelectItem value="__custom__">
{t("ai.modelCustom")}
</SelectItem>
</SelectContent>
</Select>
) : (
<Input
className="rounded-none"
value={defaultModel}
onChange={(event) => setDefaultModel(event.target.value)}
placeholder={t("ai.defaultModelPlaceholder")}
/>
)}
{detectWarning && (
<p className="text-[11px] leading-snug text-muted-foreground">
{detectWarning}
</p>
)}
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
disabled={saving}
onClick={handleAdd}
>
{saving && <Loader2 size={14} className="animate-spin" />}
{t("ai.save")}
</Button>
<Button
size="sm"
variant="outline"
disabled={saving}
onClick={() => setAdding(false)}
>
{t("ai.cancel")}
</Button>
</div>
</div>
)}
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight, Loader2, Wrench } from "lucide-react";
import type { ToolActivity } from "./use-ai-stream";
import { toolLabel } from "./labels";
/**
* Every tool call is shown, so the user can always see what the assistant
* looked at rather than having to trust the summary.
*/
export function AiToolCall({
tool,
streaming = false,
}: {
tool: ToolActivity;
/** False once the turn has ended, whatever ended it. */
streaming?: boolean;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
// A tool can only still be running while the turn is. An aborted or failed
// stream leaves calls without results, and treating those as pending left a
// spinner running forever after the conversation had finished.
const pending = !("result" in tool) && streaming;
return (
<div className="rounded-none border border-border bg-muted/50 text-xs">
<button
type="button"
className="flex w-full items-center gap-2 px-2 py-1.5 text-left"
onClick={() => setOpen((value) => !value)}
>
{open ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
{pending ? (
<Loader2 size={12} className="animate-spin" />
) : (
<Wrench size={12} />
)}
<span className="truncate">{toolLabel(tool.name)}</span>
{pending && (
<span className="text-muted-foreground">{t("ai.running")}</span>
)}
</button>
{open && (
<div className="space-y-2 border-t border-border px-2 py-2">
{/* The header reads as an action, so the actual tool id lives here. */}
<div className="font-mono text-[10px] text-muted-foreground">
{tool.name}
</div>
{Object.keys(tool.arguments).length > 0 && (
<div>
<div className="mb-1 text-muted-foreground">
{t("ai.toolArguments")}
</div>
<pre className="overflow-x-auto whitespace-pre-wrap break-words font-mono">
{JSON.stringify(tool.arguments, null, 2)}
</pre>
</div>
)}
{/*
Gated on the result existing, not on !pending: an interrupted turn
leaves a call with no result at all, which would otherwise print
"undefined" under a Result heading.
*/}
{"result" in tool && (
<div>
<div className="mb-1 text-muted-foreground">
{t("ai.toolResult")}
</div>
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words font-mono">
{JSON.stringify(tool.result, null, 2)}
</pre>
</div>
)}
</div>
)}
</div>
);
}
+212
View File
@@ -0,0 +1,212 @@
import { getErrorMessage } from "../../lib/error-message.js";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Check, Loader2, TriangleAlert, X } from "lucide-react";
import { Button } from "@/components/button";
import {
applyAiProposal,
rejectAiProposal,
type AiProposal,
} from "@/api/ai-api";
import { fieldLabel, toolLabel } from "./labels";
interface ProposalCardProps {
proposal: AiProposal;
onResolved: (
id: number,
status: "applied" | "rejected",
resultSummary?: string,
) => void;
}
function isDestructive(kind: string): boolean {
return kind.includes("delete");
}
/**
* Fields shown in their own block rather than the generic row list, so the
* card does not print the same command twice.
*/
const PROMOTED_FIELDS = new Set(["command", "explanation", "reason"]);
/** Renders the payload as readable field rows rather than raw JSON. */
function fieldRows(payload: Record<string, unknown>): Array<[string, string]> {
const rows: Array<[string, string]> = [];
const push = (key: string, value: unknown) => {
if (value === null || value === undefined || value === "") return;
if (PROMOTED_FIELDS.has(key)) return;
const text =
typeof value === "object" ? JSON.stringify(value) : String(value);
rows.push([key, text]);
};
for (const [key, value] of Object.entries(payload)) {
if (key === "changes" && value && typeof value === "object") {
for (const [childKey, childValue] of Object.entries(
value as Record<string, unknown>,
)) {
push(childKey, childValue);
}
continue;
}
push(key, value);
}
return rows;
}
function asText(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
export function ProposalCard({ proposal, onResolved }: ProposalCardProps) {
const { t } = useTranslation();
const [busy, setBusy] = useState<"apply" | "reject" | null>(null);
let payload: Record<string, unknown> = {};
try {
payload = JSON.parse(proposal.payload);
} catch {
payload = {};
}
const rows = fieldRows(payload);
const destructive = isDestructive(proposal.kind);
const resolved = proposal.status !== "pending";
const command = asText(payload.command);
const explanation = asText(payload.explanation) ?? asText(payload.reason);
// The stored summary repeats the command for run_command proposals, so a
// short action label is used instead of printing it twice.
const title =
command !== null
? t("ai.runCommandTitle")
: (proposal.summary ?? toolLabel(proposal.kind));
async function handleApply() {
setBusy("apply");
try {
const result = await applyAiProposal(proposal.id);
// The result goes into the card, not a toast: command output can be
// hundreds of lines, which covered the screen.
onResolved(proposal.id, "applied", result.summary);
} catch (error) {
toast.error(getErrorMessage(error, t("ai.proposalApplyFailed")));
} finally {
setBusy(null);
}
}
async function handleReject() {
setBusy("reject");
try {
await rejectAiProposal(proposal.id);
onResolved(proposal.id, "rejected");
} catch (error) {
toast.error(getErrorMessage(error, t("ai.proposalRejectFailed")));
} finally {
setBusy(null);
}
}
return (
<div className="rounded-none border border-border bg-muted p-2.5">
<div className="flex items-center gap-1.5">
{destructive && (
<TriangleAlert size={13} className="shrink-0 text-destructive" />
)}
<span className="min-w-0 flex-1 truncate text-xs font-medium">
{title}
</span>
{resolved && (
<span className="shrink-0 border border-border px-1.5 py-0.5 text-[10px] text-muted-foreground">
{proposal.status === "applied"
? t("ai.statusApplied")
: t("ai.statusRejected")}
</span>
)}
</div>
{/*
A command is the payload, not a field of it, so it gets its own block
rather than a label column. In the sidebar a fixed label gutter left
barely any room for the command itself.
*/}
{command && (
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-all border border-border bg-background px-2 py-1.5 font-mono text-[11px] leading-snug">
{command}
</pre>
)}
{explanation && (
<p className="mt-1.5 text-[11px] leading-snug text-muted-foreground">
{explanation}
</p>
)}
{rows.length > 0 && (
<div className="mt-2 space-y-1.5 border-t border-border pt-2">
{rows.map(([key, value]) => (
// Stacked, not a two-column row: the value is what matters and it
// needs the full width to stay readable.
<div key={key} className="min-w-0">
<span className="text-[10px] text-muted-foreground">
{fieldLabel(key)}
</span>
<div className="break-all font-mono text-[11px] leading-snug">
{value}
</div>
</div>
))}
</div>
)}
{proposal.resultSummary && (
// Command output comes back here, so it keeps the monospace treatment.
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-all border border-border bg-background px-2 py-1.5 font-mono text-[11px] leading-snug text-muted-foreground">
{proposal.resultSummary}
</pre>
)}
{!resolved && (
<div className="mt-2 flex gap-1.5">
<Button
size="sm"
className={`h-7 flex-1 text-xs ${
destructive
? ""
: "border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
}`}
variant={destructive ? "destructive" : "outline"}
disabled={busy !== null}
onClick={handleApply}
>
{busy === "apply" ? (
<Loader2 size={13} className="animate-spin" />
) : (
<Check size={13} />
)}
{t("ai.approve")}
</Button>
<Button
size="sm"
className="h-7 flex-1 text-xs"
variant="outline"
disabled={busy !== null}
onClick={handleReject}
>
{busy === "reject" ? (
<Loader2 size={13} className="animate-spin" />
) : (
<X size={13} />
)}
{t("ai.reject")}
</Button>
</div>
)}
</div>
);
}
+87
View File
@@ -0,0 +1,87 @@
/**
* Turns the internal identifiers the assistant works in into text a user can
* read. Tool names, proposal kinds and payload keys all surface in the panel,
* and raw ids like "propose_create_alert_rule" or "thresholddurationseconds"
* are not something to show anyone.
*/
/** What each tool is doing, phrased as an action rather than a function name. */
const TOOL_LABELS: Record<string, string> = {
list_hosts: "Reading hosts",
get_host: "Reading host",
list_fleets: "Reading fleets",
list_snippets: "Reading snippets",
list_automations: "Reading automations",
get_automation: "Reading automation",
list_workspaces: "Reading workspaces",
list_alert_rules: "Reading alert rules",
list_notification_channels: "Reading notification channels",
get_alert_firings: "Reading recent alerts",
list_homepage_items: "Reading homepage items",
get_command_history: "Reading command history",
get_network_topology: "Reading network topology",
propose_create_host: "Add host",
propose_update_host: "Update host",
propose_delete_host: "Delete host",
propose_create_snippet: "Create snippet",
propose_update_snippet: "Update snippet",
propose_delete_snippet: "Delete snippet",
propose_create_automation: "Create automation",
propose_create_fleet: "Create fleet",
propose_create_alert_rule: "Create alert rule",
propose_run_command: "Run command",
};
/** Readable names for the payload keys the propose tools emit. */
const FIELD_LABELS: Record<string, string> = {
hostId: "Host",
hostIds: "Hosts",
snippetId: "Snippet",
automationId: "Automation",
name: "Name",
ip: "Address",
port: "Port",
username: "Username",
folder: "Folder",
tags: "Tags",
content: "Command",
description: "Description",
definition: "Definition",
enabled: "Enabled",
triggerType: "Trigger",
thresholdValue: "Threshold",
thresholdDurationSeconds: "Duration (seconds)",
cooldownMinutes: "Cooldown (minutes)",
};
const MENTION_LABELS: Record<string, string> = {
host: "Host",
snippet: "Snippet",
automation: "Automation",
};
/**
* Splits snake_case and camelCase into words and capitalises the first.
* Used whenever an identifier has no explicit label, so something added later
* still reads as words rather than one run-on token.
*/
export function humanize(value: string): string {
const spaced = value
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/[_-]+/g, " ")
.trim();
if (!spaced) return value;
return spaced.charAt(0).toUpperCase() + spaced.slice(1).toLowerCase();
}
export function toolLabel(name: string): string {
return TOOL_LABELS[name] ?? humanize(name);
}
export function fieldLabel(key: string): string {
return FIELD_LABELS[key] ?? humanize(key);
}
export function mentionLabel(kind: string): string {
return MENTION_LABELS[kind] ?? humanize(kind);
}
+220
View File
@@ -0,0 +1,220 @@
import { getErrorMessage } from "../../lib/error-message.js";
import { useCallback, useRef, useState } from "react";
import { authApi } from "@/main-axios";
import type { AiProposal } from "@/api/ai-api";
/**
* Reuses whatever base authApi resolved to, so the stream follows the same
* dev proxy, Electron localhost and reverse-proxy base path rules as every
* other call instead of hardcoding an origin.
*/
function streamUrl(): string {
const base = (authApi.defaults.baseURL ?? "").replace(/\/+$/, "");
return `${base}/ai/chat/stream`;
}
/**
* Drives the chat stream.
*
* EventSource cannot POST, and the request carries a message body, so this
* reads the SSE frames off a fetch response by hand.
*/
export interface ToolActivity {
id: string;
name: string;
arguments: Record<string, unknown>;
result?: unknown;
}
export interface StreamState {
streaming: boolean;
assistantText: string;
tools: ToolActivity[];
proposals: AiProposal[];
error: string | null;
conversationId: number | null;
}
const INITIAL: StreamState = {
streaming: false,
assistantText: "",
tools: [],
proposals: [],
error: null,
conversationId: null,
};
export function useAiStream() {
const [state, setState] = useState<StreamState>(INITIAL);
const abortRef = useRef<AbortController | null>(null);
const reset = useCallback(() => {
setState(INITIAL);
}, []);
const stop = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setState((prev) => ({ ...prev, streaming: false }));
}, []);
const send = useCallback(
async (input: {
message: string;
providerId: number;
model?: string;
conversationId?: number | null;
activeTab?: string | null;
onComplete?: (conversationId: number | null, reply: string) => void;
}) => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setState({
streaming: true,
assistantText: "",
tools: [],
proposals: [],
error: null,
conversationId: input.conversationId ?? null,
});
let conversationId = input.conversationId ?? null;
let replyText = "";
let toolSequence = 0;
try {
const response = await fetch(streamUrl(), {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
signal: controller.signal,
body: JSON.stringify({
message: input.message,
providerId: input.providerId,
model: input.model,
conversationId: input.conversationId ?? undefined,
activeTab: input.activeTab ?? undefined,
}),
});
if (!response.ok) {
let message = "The assistant could not be reached";
try {
message = (await response.json()).error ?? message;
} catch {
// Keep the generic message.
}
setState((prev) => ({ ...prev, streaming: false, error: message }));
return;
}
const reader = response.body?.getReader();
if (!reader) {
setState((prev) => ({
...prev,
streaming: false,
error: "The assistant returned an empty response",
}));
return;
}
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newlineIndex = buffer.indexOf("\n");
while (newlineIndex !== -1) {
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
newlineIndex = buffer.indexOf("\n");
if (!line.startsWith("data:")) continue;
let event: any;
try {
event = JSON.parse(line.slice(5).trim());
} catch {
continue;
}
if (event.type === "conversation") {
conversationId = event.conversationId;
setState((prev) => ({
...prev,
conversationId: event.conversationId,
}));
} else if (event.type === "token") {
replyText += event.text;
setState((prev) => ({
...prev,
assistantText: prev.assistantText + event.text,
}));
} else if (event.type === "tool_call") {
// The id is assigned here rather than inside the updater: React
// may run an updater more than once, and deriving the id from
// prev.tools.length appended a duplicate entry that no result
// could ever match, leaving it stuck on "running".
const activityId = `tool-${toolSequence++}`;
setState((prev) => ({
...prev,
tools: [
...prev.tools,
{
id: activityId,
name: event.name,
arguments: event.arguments ?? {},
},
],
}));
} else if (event.type === "tool_result") {
setState((prev) => {
const tools = [...prev.tools];
// Attach to the most recent call of this tool awaiting a result.
for (let i = tools.length - 1; i >= 0; i -= 1) {
if (tools[i].name === event.name && !("result" in tools[i])) {
tools[i] = { ...tools[i], result: event.result };
break;
}
}
return { ...prev, tools };
});
} else if (event.type === "proposal") {
setState((prev) => ({
...prev,
proposals: [...prev.proposals, event.proposal],
}));
} else if (event.type === "error") {
setState((prev) => ({ ...prev, error: event.message }));
}
}
}
setState((prev) => ({ ...prev, streaming: false }));
input.onComplete?.(conversationId, replyText);
} catch (error) {
if (controller.signal.aborted) {
setState((prev) => ({ ...prev, streaming: false }));
return;
}
setState((prev) => ({
...prev,
streaming: false,
error: getErrorMessage(error, "The assistant stopped unexpectedly"),
}));
} finally {
abortRef.current = null;
}
},
[],
);
return { state, send, stop, reset, setState };
}
+108
View File
@@ -0,0 +1,108 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { getSSHHosts } from "@/api/ssh-host-management-api";
import { getSnippets } from "@/api/snippets-api";
import { listAutomations } from "@/api/automations-api";
/**
* Backs the @-mention picker in the composer.
*
* A mention is only ever a name the assistant reads in the message text; it
* does not grant access to anything. The assistant still has to call a read
* tool to see the referenced item, so every access stays visible in the
* transcript rather than being silently attached to the prompt.
*/
export type MentionKind = "host" | "snippet" | "automation";
export interface MentionItem {
kind: MentionKind;
id: number;
label: string;
detail?: string;
}
/** Extracts the "@partial" the caret currently sits in, if any. */
export function activeMentionQuery(
value: string,
caret: number,
): { query: string; start: number } | null {
const before = value.slice(0, caret);
const at = before.lastIndexOf("@");
if (at === -1) return null;
// Only trigger at a word boundary, so an email address does not open it.
if (at > 0 && !/\s/.test(before[at - 1])) return null;
const query = before.slice(at + 1);
if (/\s/.test(query)) return null;
return { query, start: at };
}
export function useMentions(enabled: boolean) {
const [items, setItems] = useState<MentionItem[]>([]);
useEffect(() => {
if (!enabled) return;
let cancelled = false;
(async () => {
const [hosts, snippets, automations] = await Promise.allSettled([
getSSHHosts(),
getSnippets(),
listAutomations(),
]);
if (cancelled) return;
const collected: MentionItem[] = [];
if (hosts.status === "fulfilled") {
for (const host of (hosts.value ?? []) as any[]) {
collected.push({
kind: "host",
id: host.id,
label: host.name || host.ip,
detail: host.ip,
});
}
}
if (snippets.status === "fulfilled") {
for (const snippet of (snippets.value ?? []) as any[]) {
collected.push({
kind: "snippet",
id: snippet.id,
label: snippet.name,
});
}
}
if (automations.status === "fulfilled") {
for (const automation of (automations.value ?? []) as any[]) {
collected.push({
kind: "automation",
id: automation.id,
label: automation.name,
});
}
}
setItems(collected);
})();
return () => {
cancelled = true;
};
}, [enabled]);
const search = useCallback(
(query: string): MentionItem[] => {
const needle = query.trim().toLowerCase();
const matches = needle
? items.filter((item) => item.label.toLowerCase().includes(needle))
: items;
return matches.slice(0, 8);
},
[items],
);
return useMemo(() => ({ items, search }), [items, search]);
}
+13 -13
View File
@@ -2,6 +2,7 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { DockerManager } from "@/features/docker/DockerManager.tsx";
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
interface DockerAppProps {
hostId?: string;
@@ -11,26 +12,25 @@ const DockerApp: React.FC<DockerAppProps> = ({ hostId }) => {
const { t } = useTranslation();
return (
<FullScreenAppWrapper hostId={hostId}>
{(hostConfig, loading) => {
if (loading) {
{(hostConfig, phase) => {
if (phase === "loading") {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
<p className="text-muted-foreground">
{t("hosts.loadingHost")}
</p>
</div>
<div className="relative h-full w-full">
<ConnectionScreen
status="connecting"
message={t("hosts.loadingHost")}
/>
</div>
);
}
if (!hostConfig) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-red-500 mb-4">{t("hosts.hostNotFound")}</p>
</div>
<div className="relative h-full w-full">
<ConnectionScreen
status="disconnected"
message={t("hosts.hostNotFound")}
/>
</div>
);
}
+102 -134
View File
@@ -26,18 +26,20 @@ import {
logActivity,
getSSHHosts,
} from "@/main-axios.ts";
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
import { ContainerList } from "./components/ContainerList.tsx";
import { ContainerDetail } from "./components/ContainerDetail.tsx";
import { TOTPDialog } from "@/ssh/dialogs/TOTPDialog.tsx";
import { SSHAuthDialog } from "@/ssh/dialogs/SSHAuthDialog.tsx";
import { WarpgateDialog } from "@/ssh/dialogs/WarpgateDialog.tsx";
import { useTabsSafe } from "@/shell/TabContext.tsx";
import { useAreaPreferences } from "@/contexts/UiPreferencesContext";
import {
ConnectionLogProvider,
useConnectionLog,
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
import { ConnectionLog } from "@/ssh/connection-log/ConnectionLog.tsx";
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
import { useConnectionRetry } from "@/lib/useConnectionRetry.ts";
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling.ts";
import type { LogEntry } from "@/types/connection-log.ts";
interface DockerManagerProps {
@@ -65,16 +67,13 @@ function DockerManagerInner({
onClose,
}: DockerManagerProps): React.ReactElement {
const { t } = useTranslation();
const {
addLog,
setLogs,
clearLogs,
isExpanded: isConnectionLogExpanded,
} = useConnectionLog();
const { addLog, setLogs, clearLogs } = useConnectionLog();
const { currentTab, removeTab } = useTabsSafe();
const dockerPrefs = useAreaPreferences("docker");
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
const [sessionId, setSessionId] = React.useState<string | null>(null);
const [containers, setContainers] = React.useState<DockerContainer[]>([]);
const containersSignatureRef = React.useRef("");
const [selectedContainer, setSelectedContainer] = React.useState<
string | null
>(null);
@@ -82,8 +81,14 @@ function DockerManagerInner({
const [dockerValidation, setDockerValidation] =
React.useState<DockerValidation | null>(null);
const [isValidating, setIsValidating] = React.useState(false);
const [viewMode, setViewMode] = React.useState<"list" | "detail">("list");
// Initial view follows the interface preset; switching it afterwards is a
// per-session choice, same as before.
const [viewMode, setViewMode] = React.useState<"list" | "detail">(
dockerPrefs.viewMode,
);
const [isLoadingContainers, setIsLoadingContainers] = React.useState(false);
const [hasLoadedContainersOnce, setHasLoadedContainersOnce] =
React.useState(false);
const [totpRequired, setTotpRequired] = React.useState(false);
const [totpSessionId, setTotpSessionId] = React.useState<string | null>(null);
const [totpPrompt, setTotpPrompt] = React.useState<string>("");
@@ -98,7 +103,6 @@ function DockerManagerInner({
const [authReason, setAuthReason] = React.useState<
"no_keyboard" | "auth_failed" | "timeout"
>("no_keyboard");
const [hasConnectionError, setHasConnectionError] = React.useState(false);
const [search, setSearch] = React.useState("");
const [statusFilter, setStatusFilter] = React.useState("all");
const [retryCount, setRetryCount] = React.useState(0);
@@ -195,7 +199,6 @@ function DockerManagerInner({
}
setIsConnecting(true);
setHasConnectionError(false);
clearLogs();
const sid = `docker-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
@@ -243,7 +246,6 @@ function DockerManagerInner({
setIsValidating(false);
if (!validation.available) {
setHasConnectionError(true);
addLog({
type: "error",
stage: "validation",
@@ -252,15 +254,16 @@ function DockerManagerInner({
? `Error code: ${validation.code}`
: undefined,
});
dockerConnectRetryRef.current.markFailed();
} else {
logDockerActivity();
setTimeout(() => clearLogs(), 1000);
dockerConnectRetryRef.current.markConnected();
}
} catch (error) {
const dockerError = error as DockerConnectionError;
setIsConnecting(false);
setIsValidating(false);
setHasConnectionError(true);
if (Array.isArray(dockerError.connectionLogs)) {
setLogs(dockerError.connectionLogs);
@@ -271,6 +274,7 @@ function DockerManagerInner({
message: dockerError.message || t("docker.connectionFailed"),
});
}
dockerConnectRetryRef.current.markFailed();
} finally {
setIsConnecting(false);
}
@@ -314,37 +318,37 @@ function DockerManagerInner({
}, [sessionId]);
React.useEffect(() => {
if (!sessionId || !isVisible || !dockerValidation?.available) return;
setHasLoadedContainersOnce(false);
}, [sessionId]);
let cancelled = false;
const pollContainers = async () => {
try {
setIsLoadingContainers(true);
const data = await listDockerContainers(sessionId, true);
if (!cancelled) {
setContainers(data);
}
} catch {
// Silently handle polling errors
} finally {
if (!cancelled) {
setIsLoadingContainers(false);
}
}
};
pollContainers();
const interval = setInterval(() => {
if (document.visibilityState === "hidden") return;
void pollContainers();
}, 5000);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [sessionId, isVisible, dockerValidation?.available]);
useAdaptivePolling(
async () => {
if (!sessionId) return;
setIsLoadingContainers((loading) =>
hasLoadedContainersOnce ? loading : true,
);
const data = await listDockerContainers(sessionId, true);
const signature = JSON.stringify(data);
const changed = signature !== containersSignatureRef.current;
containersSignatureRef.current = signature;
setContainers(data);
setIsLoadingContainers(false);
setHasLoadedContainersOnce(true);
return changed;
},
{
minIntervalMs: 5000,
maxIntervalMs: 30000,
stablePollsPerStep: 3,
},
!!sessionId && isVisible && !!dockerValidation?.available,
{
onError: () => {
setIsLoadingContainers(false);
setHasLoadedContainersOnce(true);
},
},
);
const handleBack = React.useCallback(() => {
setViewMode("list");
@@ -370,7 +374,6 @@ function DockerManagerInner({
setIsValidating(false);
if (!validation.available) {
setHasConnectionError(true);
addLog({
type: "error",
stage: "validation",
@@ -379,18 +382,20 @@ function DockerManagerInner({
? `Error code: ${validation.code}`
: undefined,
});
dockerConnectRetryRef.current.markFailed();
} else {
logDockerActivity();
dockerConnectRetryRef.current.markConnected();
}
}
} catch (error) {
console.error("TOTP verification failed:", error);
setHasConnectionError(true);
addLog({
type: "error",
stage: "auth",
message: t("docker.totpVerificationFailed"),
});
dockerConnectRetryRef.current.markFailed();
} finally {
setIsConnecting(false);
}
@@ -426,7 +431,6 @@ function DockerManagerInner({
setIsValidating(false);
if (!validation.available) {
setHasConnectionError(true);
addLog({
type: "error",
stage: "validation",
@@ -435,18 +439,20 @@ function DockerManagerInner({
? `Error code: ${validation.code}`
: undefined,
});
dockerConnectRetryRef.current.markFailed();
} else {
logDockerActivity();
dockerConnectRetryRef.current.markConnected();
}
}
} catch (error) {
console.error("Warpgate verification failed:", error);
setHasConnectionError(true);
addLog({
type: "error",
stage: "auth",
message: t("docker.warpgateVerificationFailed"),
});
dockerConnectRetryRef.current.markFailed();
} finally {
setIsConnecting(false);
}
@@ -526,19 +532,20 @@ function DockerManagerInner({
setIsValidating(false);
if (!validation.available) {
setHasConnectionError(true);
dockerConnectRetryRef.current.markFailed();
} else {
logDockerActivity();
dockerConnectRetryRef.current.markConnected();
}
} catch (error) {
setIsConnecting(false);
setIsValidating(false);
setHasConnectionError(true);
addLog({
type: "error",
stage: "connection",
message: error?.message || t("docker.connectionFailed"),
});
dockerConnectRetryRef.current.markFailed();
} finally {
setIsConnecting(false);
}
@@ -553,12 +560,25 @@ function DockerManagerInner({
const handleRetry = () => {
initializingRef.current = false;
setSessionId(null);
setHasConnectionError(false);
setDockerValidation(null);
clearLogs();
setRetryCount((c) => c + 1);
};
const dockerConnectRetry = useConnectionRetry({
connect: () => {
handleRetry();
},
enabled:
!!currentHostConfig?.enableDocker &&
!totpRequired &&
!warpgateRequired &&
!showAuthDialog,
autoStart: false,
});
const dockerConnectRetryRef = React.useRef(dockerConnectRetry);
dockerConnectRetryRef.current = dockerConnectRetry;
const topMarginPx = isTopbarOpen ? 74 : 16;
const leftMarginPx = 8;
const bottomMarginPx = 8;
@@ -609,53 +629,32 @@ function DockerManagerInner({
if (isConnecting || isValidating) {
return (
<div style={wrapperStyle} className={`${containerClass} relative`}>
<div className="h-full w-full flex flex-col">
<div className="flex-1 overflow-hidden min-h-0 relative">
<SimpleLoader
visible={!isConnectionLogExpanded}
message={
isValidating ? t("docker.validating") : t("docker.connecting")
}
/>
</div>
</div>
<ConnectionLog
isConnecting={isConnecting}
isConnected={!!sessionId && !!dockerValidation?.available}
hasConnectionError={hasConnectionError}
position={hasConnectionError ? "top" : "bottom"}
<ConnectionScreen
status={dockerConnectRetry.status}
message={
isValidating ? t("docker.validating") : t("docker.connecting")
}
attempt={dockerConnectRetry.attempt}
maxAttempts={dockerConnectRetry.maxAttempts}
nextRetryInMs={dockerConnectRetry.nextRetryInMs}
onManualRetry={dockerConnectRetry.retryNow}
/>
</div>
);
}
if (dockerValidation && !dockerValidation.available) {
const isError =
hasConnectionError || (!!dockerValidation && !dockerValidation.available);
return (
<div style={wrapperStyle} className={`${containerClass} relative`}>
{!isConnectionLogExpanded && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 z-10">
<Box className="size-12 opacity-20" />
<p className="text-sm text-muted-foreground opacity-60">
{t("docker.connectionFailed")}
</p>
<Button
variant="outline"
size="default"
onClick={handleRetry}
className="gap-2 font-semibold"
>
<RefreshCw className="size-3.5" />
{t("terminal.retry")}
</Button>
</div>
)}
<ConnectionLog
isConnecting={isConnecting}
isConnected={!!sessionId && !!dockerValidation?.available}
hasConnectionError={isError}
position={isError ? "top" : "bottom"}
<ConnectionScreen
status={dockerConnectRetry.status}
message={t("docker.connectionFailed")}
attempt={dockerConnectRetry.attempt}
maxAttempts={dockerConnectRetry.maxAttempts}
nextRetryInMs={dockerConnectRetry.nextRetryInMs}
onManualRetry={dockerConnectRetry.retryNow}
retryLabel={t("terminal.retry")}
logPosition="top"
/>
</div>
);
@@ -663,17 +662,7 @@ function DockerManagerInner({
return (
<div style={wrapperStyle} className={`${containerClass} relative`}>
<div
className="h-full w-full flex flex-col flex-1 min-h-0 overflow-hidden"
style={{
visibility:
(hasConnectionError ||
(!!dockerValidation && !dockerValidation.available)) &&
isConnectionLogExpanded
? "hidden"
: "visible",
}}
>
<div className="h-full w-full flex flex-col flex-1 min-h-0 overflow-hidden">
{viewMode === "detail" &&
sessionId &&
selectedContainer &&
@@ -699,6 +688,10 @@ function DockerManagerInner({
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{dockerValidation?.version
? t("docker.version", {
runtime:
dockerValidation.runtime === "podman"
? "Podman"
: "Docker",
version: dockerValidation.version,
})
: t("docker.manager")}
@@ -753,7 +746,7 @@ function DockerManagerInner({
</Card>
{sessionId ? (
isLoadingContainers && containers.length === 0 ? (
!hasLoadedContainersOnce ? (
<div className="flex flex-col items-center justify-center h-full opacity-40 py-20">
<RefreshCw className="size-8 animate-spin mb-4" />
<span className="text-sm font-semibold">
@@ -806,40 +799,15 @@ function DockerManagerInner({
}}
/>
)}
<SimpleLoader
visible={isConnecting && !isConnectionLogExpanded}
<ConnectionScreen
status={dockerConnectRetry.status}
message={t("docker.connecting")}
/>
{hasConnectionError && !isConnectionLogExpanded && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 z-10">
<Box className="size-12 opacity-20" />
<p className="text-sm text-muted-foreground opacity-60">
{t("docker.connectionFailed")}
</p>
<Button
variant="outline"
size="default"
onClick={handleRetry}
className="gap-2 font-semibold"
>
<RefreshCw className="size-3.5" />
{t("terminal.retry")}
</Button>
</div>
)}
<ConnectionLog
isConnecting={isConnecting}
isConnected={!!sessionId && !!dockerValidation?.available}
hasConnectionError={
hasConnectionError ||
(!!dockerValidation && !dockerValidation.available)
}
position={
hasConnectionError ||
(!!dockerValidation && !dockerValidation.available)
? "top"
: "bottom"
}
attempt={dockerConnectRetry.attempt}
maxAttempts={dockerConnectRetry.maxAttempts}
nextRetryInMs={dockerConnectRetry.nextRetryInMs}
onManualRetry={dockerConnectRetry.retryNow}
retryLabel={t("terminal.retry")}
logPosition="top"
/>
</div>
);
@@ -1,9 +1,10 @@
import { getErrorMessage } from "../../../lib/error-message.js";
import React from "react";
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 { copyToClipboard, readFromClipboard } from "@/lib/clipboard";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { Button } from "@/components/button.tsx";
import {
@@ -23,12 +24,17 @@ import { Terminal as TerminalIcon, Power, PowerOff } from "lucide-react";
import { toast } from "sonner";
import type { SSHHost } from "@/types";
import { isElectron } from "@/main-axios.ts";
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
import { useTranslation } from "react-i18next";
import { resolveTermixThemeColors } from "@/features/terminal/terminal-theme";
import { DEFAULT_TERMINAL_CONFIG, TERMINAL_FONTS } from "@/lib/terminal-themes";
import { ensureTerminalFontsLoaded } from "@/features/terminal/terminal-global-styles";
import { useTheme } from "@/components/theme-provider";
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
import {
ConnectionLogProvider,
useConnectionLog,
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
import { useConnectionRetry } from "@/lib/useConnectionRetry.ts";
interface ConsoleTerminalProps {
containerId: string;
@@ -37,7 +43,17 @@ interface ConsoleTerminalProps {
hostConfig: SSHHost;
}
export function ConsoleTerminal({
export function ConsoleTerminal(
props: ConsoleTerminalProps,
): React.ReactElement {
return (
<ConnectionLogProvider>
<ConsoleTerminalInner {...props} />
</ConnectionLogProvider>
);
}
function ConsoleTerminalInner({
containerId,
containerName,
containerState,
@@ -46,6 +62,7 @@ export function ConsoleTerminal({
const { t } = useTranslation();
const { theme: appTheme } = useTheme();
const { instance: terminal, ref: xtermRef } = useXTerm();
const { addLog, clearLogs } = useConnectionLog();
const terminalConfig = React.useMemo(
() => ({ ...DEFAULT_TERMINAL_CONFIG, ...hostConfig.terminalConfig }),
@@ -93,10 +110,7 @@ export function ConsoleTerminal({
terminal.options.lineHeight = terminalConfig.lineHeight;
const readTextFromClipboard = async (): Promise<string> => {
if (window.electronClipboard) {
return window.electronClipboard.readText();
}
return (await navigator.clipboard?.readText?.()) ?? "";
return readFromClipboard();
};
const writeTextToClipboard = async (text: string): Promise<void> => {
@@ -260,6 +274,8 @@ export function ConsoleTerminal({
wsRef.current = null;
}
setIsConnected(false);
retryRef.current.reset();
clearLogs();
if (terminal) {
try {
terminal.clear();
@@ -267,7 +283,7 @@ export function ConsoleTerminal({
// Terminal clear can fail after disposal.
}
}
}, [terminal]);
}, [terminal, clearLogs]);
const connect = React.useCallback(async () => {
if (!terminal || containerState !== "running") {
@@ -276,6 +292,11 @@ export function ConsoleTerminal({
}
setIsConnecting(true);
addLog({
type: "info",
stage: "docker_connecting",
message: t("docker.connectingTo", { containerName }),
});
try {
if (fitAddonRef.current) {
@@ -304,7 +325,6 @@ export function ConsoleTerminal({
localPort: 30009,
localPath: "/docker/console/",
remotePath: "/docker/console/",
includeLocalJwt: false,
});
if (!resolvedUrl) {
setIsConnecting(false);
@@ -348,6 +368,7 @@ export function ConsoleTerminal({
case "connected":
setIsConnected(true);
setIsConnecting(false);
retryRef.current.markConnected();
if (msg.data?.shellChanged) {
toast.warning(
@@ -376,6 +397,7 @@ export function ConsoleTerminal({
case "disconnected":
setIsConnected(false);
setIsConnecting(false);
retryRef.current.reset();
terminal.write(
`\r\n\x1b[1;33m${msg.message || t("docker.disconnected")}\x1b[0m\r\n`,
);
@@ -391,6 +413,12 @@ export function ConsoleTerminal({
terminal.write(
`\r\n\x1b[1;31m${t("docker.errorMessage", { message: msg.message })}\x1b[0m\r\n`,
);
addLog({
type: "error",
stage: "error",
message: msg.message || t("docker.consoleError"),
});
retryRef.current.markFailed();
break;
}
} catch (error) {
@@ -403,6 +431,12 @@ export function ConsoleTerminal({
setIsConnecting(false);
setIsConnected(false);
toast.error(t("docker.failedToConnect"));
addLog({
type: "error",
stage: "error",
message: t("docker.failedToConnect"),
});
retryRef.current.markFailed();
};
if (pingIntervalRef.current) {
@@ -440,9 +474,10 @@ export function ConsoleTerminal({
});
} catch (error) {
setIsConnecting(false);
toast.error(
`Failed to connect: ${error instanceof Error ? error.message : "Unknown error"}`,
);
const message = `Failed to connect: ${getErrorMessage(error)}`;
toast.error(message);
addLog({ type: "error", stage: "error", message });
retryRef.current.markFailed();
}
}, [
terminal,
@@ -452,8 +487,16 @@ export function ConsoleTerminal({
selectedShell,
containerName,
t,
addLog,
]);
const retry = useConnectionRetry({
connect,
autoStart: false,
});
const retryRef = React.useRef(retry);
retryRef.current = retry;
React.useEffect(() => {
return () => {
if (pingIntervalRef.current) {
@@ -559,30 +602,40 @@ export function ConsoleTerminal({
style={{ display: isConnected ? "block" : "none" }}
/>
{!isConnected && !isConnecting && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center space-y-2">
<TerminalIcon className="h-12 w-12 text-muted-foreground/50 mx-auto" />
<p className="text-muted-foreground">
{t("docker.notConnected")}
</p>
<p className="text-muted-foreground text-sm">
{t("docker.clickToConnect")}
</p>
{!isConnected &&
!isConnecting &&
retry.status !== "error" &&
retry.status !== "disconnected" && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center space-y-2">
<TerminalIcon className="h-12 w-12 text-muted-foreground/50 mx-auto" />
<p className="text-muted-foreground">
{t("docker.notConnected")}
</p>
<p className="text-muted-foreground text-sm">
{t("docker.clickToConnect")}
</p>
</div>
</div>
</div>
)}
)}
{isConnecting && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<SimpleLoader size="lg" />
<p className="text-muted-foreground mt-4">
{t("docker.connectingTo", { containerName })}
</p>
</div>
</div>
)}
{!isConnected &&
(isConnecting ||
retry.status === "error" ||
retry.status === "disconnected") && (
<ConnectionScreen
status={isConnecting ? "connecting" : retry.status}
message={t("docker.connectingTo", { containerName })}
attempt={retry.attempt}
maxAttempts={retry.maxAttempts}
nextRetryInMs={retry.nextRetryInMs}
onManualRetry={() => {
clearLogs();
retry.retryNow();
}}
retryLabel={t("docker.connect")}
/>
)}
</CardContent>
</Card>
</div>
@@ -1,3 +1,4 @@
import { getErrorMessage } from "../../../lib/error-message.js";
import React from "react";
import { Card } from "@/components/card.tsx";
import { Button } from "@/components/button.tsx";
@@ -87,7 +88,7 @@ export function ContainerCard({
} catch (err) {
toast.error(
t("docker.failedToStartContainer", {
error: err instanceof Error ? err.message : "Unknown error",
error: getErrorMessage(err),
}),
);
} finally {
@@ -105,7 +106,7 @@ export function ContainerCard({
} catch (err) {
toast.error(
t("docker.failedToStopContainer", {
error: err instanceof Error ? err.message : "Unknown error",
error: getErrorMessage(err),
}),
);
} finally {
@@ -123,7 +124,7 @@ export function ContainerCard({
} catch (err) {
toast.error(
t("docker.failedToRestartContainer", {
error: err instanceof Error ? err.message : "Unknown error",
error: getErrorMessage(err),
}),
);
} finally {
@@ -147,7 +148,7 @@ export function ContainerCard({
toast.error(
t("docker.failedToTogglePauseContainer", {
action: container.state === "paused" ? "unpause" : "pause",
error: err instanceof Error ? err.message : "Unknown error",
error: getErrorMessage(err),
}),
);
} finally {
@@ -175,7 +176,7 @@ export function ContainerCard({
} catch (err) {
toast.error(
t("docker.failedToRemoveContainer", {
error: err instanceof Error ? err.message : "Unknown error",
error: getErrorMessage(err),
}),
);
} finally {
@@ -1,3 +1,4 @@
import { getErrorMessage } from "../../../lib/error-message.js";
import React from "react";
import {
Activity,
@@ -13,6 +14,7 @@ import { getContainerStats } from "@/main-axios.ts";
import { useTranslation } from "react-i18next";
import { SectionCard } from "@/components/section-card";
import { DockerBadge } from "./ContainerCard.tsx";
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling.ts";
interface ContainerStatsProps {
sessionId: string;
@@ -31,31 +33,43 @@ export function ContainerStats({
const [stats, setStats] = React.useState<DockerStats | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const statsRef = React.useRef(stats);
statsRef.current = stats;
const fetchStats = React.useCallback(async () => {
if (containerState !== "running") return;
if (containerState !== "running") return false;
setIsLoading(true);
setError(null);
try {
const data = await getContainerStats(sessionId, containerId);
const previous = statsRef.current;
const changed =
!previous ||
Math.abs(parseFloat(data.cpu) - parseFloat(previous.cpu)) >= 1.5 ||
Math.abs(
parseFloat(data.memoryPercent) - parseFloat(previous.memoryPercent),
) >= 1 ||
data.pids !== previous.pids;
statsRef.current = data;
setStats(data);
return changed;
} catch (err) {
setError(
err instanceof Error ? err.message : t("docker.failedToFetchStats"),
);
setError(getErrorMessage(err, t("docker.failedToFetchStats")));
} finally {
setIsLoading(false);
}
}, [sessionId, containerId, containerState, t]);
React.useEffect(() => {
fetchStats();
const interval = setInterval(() => {
if (document.visibilityState === "hidden") return;
void fetchStats();
}, 2000);
return () => clearInterval(interval);
}, [fetchStats]);
useAdaptivePolling(
fetchStats,
{
minIntervalMs: 2_000,
maxIntervalMs: 12_000,
stablePollsPerStep: 2,
maxRequestDutyCycle: 0.2,
},
containerState === "running",
);
if (containerState !== "running") {
return (
+26 -15
View File
@@ -1,3 +1,4 @@
import { getErrorMessage } from "../../../lib/error-message.js";
import React from "react";
import { Button } from "@/components/button.tsx";
import { Input } from "@/components/input.tsx";
@@ -7,6 +8,7 @@ import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import type { DockerLogOptions } from "@/types";
import { getContainerLogs, downloadContainerLogs } from "@/main-axios.ts";
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling.ts";
interface LogViewerProps {
sessionId: string;
@@ -65,6 +67,8 @@ export function LogViewer({
const [autoRefresh, setAutoRefresh] = React.useState(false);
const [logSearch, setLogSearch] = React.useState("");
const logsEndRef = React.useRef<HTMLDivElement>(null);
const rawLogsRef = React.useRef(rawLogs);
rawLogsRef.current = rawLogs;
const fetchLogs = React.useCallback(async () => {
setIsLoading(true);
@@ -74,11 +78,17 @@ export function LogViewer({
timestamps: showTimestamps,
};
const data = await getContainerLogs(sessionId, containerId, options);
setRawLogs(data.logs.split("\n").filter(Boolean));
const next = data.logs.split("\n").filter(Boolean);
const changed =
next.length !== rawLogsRef.current.length ||
next.some((line, index) => line !== rawLogsRef.current[index]);
if (changed) {
rawLogsRef.current = next;
setRawLogs(next);
}
return changed;
} catch (error) {
toast.error(
`Failed to fetch logs: ${error instanceof Error ? error.message : "Unknown error"}`,
);
toast.error(`Failed to fetch logs: ${getErrorMessage(error)}`);
} finally {
setIsLoading(false);
}
@@ -88,14 +98,17 @@ export function LogViewer({
fetchLogs();
}, [fetchLogs]);
React.useEffect(() => {
if (!autoRefresh) return;
const interval = setInterval(() => {
if (document.visibilityState === "hidden") return;
void fetchLogs();
}, 3000);
return () => clearInterval(interval);
}, [autoRefresh, fetchLogs]);
useAdaptivePolling(
fetchLogs,
{
minIntervalMs: 3_000,
maxIntervalMs: 18_000,
stablePollsPerStep: 2,
maxRequestDutyCycle: 0.2,
},
autoRefresh,
{ runImmediately: false },
);
React.useEffect(() => {
if (autoRefresh && logsEndRef.current) {
@@ -119,9 +132,7 @@ export function LogViewer({
document.body.removeChild(a);
toast.success(t("docker.logsDownloaded"));
} catch (error) {
toast.error(
`Failed to download logs: ${error instanceof Error ? error.message : "Unknown error"}`,
);
toast.error(`Failed to download logs: ${getErrorMessage(error)}`);
} finally {
setIsDownloading(false);
}
+354 -100
View File
@@ -1,3 +1,4 @@
import { getErrorMessage } from "../../lib/error-message.js";
/* eslint-disable react-hooks/exhaustive-deps */
import React, {
useState,
@@ -6,9 +7,10 @@ import React, {
useCallback,
useMemo,
} from "react";
import { asHttpError } from "@/lib/http-error";
import { cn } from "@/lib/utils.ts";
import { FileManagerGrid } from "./FileManagerGrid.tsx";
import { FileManagerSidebar } from "./FileManagerSidebar.tsx";
import { FileManagerSidebar, type SidebarItem } from "./FileManagerSidebar.tsx";
import { FileManagerContextMenu } from "./FileManagerContextMenu.tsx";
import { useFileSelection } from "./hooks/useFileSelection.ts";
import { useDragAndDrop } from "./hooks/useDragAndDrop.ts";
@@ -27,17 +29,20 @@ import { FileManagerDialogs } from "./FileManagerDialogs.tsx";
import { PassphraseDialog } from "@/ssh/dialogs/PassphraseDialog.tsx";
import { FileManagerToolbar } from "./FileManagerToolbar.tsx";
import { TransferToHostDialog } from "./components/TransferToHostDialog.tsx";
import { FileManagerTrashDialog } from "./FileManagerTrashDialog.tsx";
import { TerminalWindow } from "./components/TerminalWindow.tsx";
import type { SSHHost, FileItem } from "@/types/index";
import {
ConnectionLogProvider,
useConnectionLog,
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
import { ConnectionLog } from "@/ssh/connection-log/ConnectionLog.tsx";
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
import { ConnectionLogPanel } from "@/components/connection/ConnectionLogPanel.tsx";
import { useConnectionRetry } from "@/lib/useConnectionRetry.ts";
import { copyToClipboard } from "@/lib/clipboard.ts";
import {
listSSHFiles,
readSSHFile,
resolveSSHPath,
uploadSSHFile,
createSSHFile,
@@ -69,9 +74,18 @@ import {
type TransferMethodPreference,
type DiskFilesystem,
} from "@/main-axios.ts";
import {
getAdaptiveResourceBudget,
markAdaptiveResourceUsed,
runAdaptiveBackgroundTask,
} from "@/lib/adaptive-resource-budget";
import { shouldPrefetchFileContent } from "@/lib/file-content-request-cache";
import {
markFilePreviewUsed,
preloadFilePreview,
} from "./file-preview-preload";
import { beginTransferProgressMonitoring } from "./transferProgressMonitor.tsx";
import { createFormatTransferMetrics } from "./transferMetricsFormat.ts";
import type { SidebarItem } from "./FileManagerSidebar.tsx";
import type {
CreateIntent,
FileManagerProps,
@@ -79,6 +93,18 @@ import type {
SSHConnectionError,
} from "./file-manager-types.ts";
import { formatFileSize } from "./file-manager-utils.ts";
import {
invalidateCachedFileList,
peekCachedFileList,
updateCachedFileList,
} from "@/lib/file-list-request-cache";
import {
addOptimisticItem,
childPath,
removePaths,
renameOptimisticItem,
restoreItems,
} from "./optimistic-file-list";
const LARGE_FILE_WARNING_SIZE = 50 * 1024 * 1024;
@@ -97,16 +123,14 @@ function FileManagerContent({
[t],
);
const { confirmWithToast } = useConfirmation();
const {
addLog,
clearLogs,
isExpanded: isConnectionLogExpanded,
} = useConnectionLog();
const { addLog, clearLogs } = useConnectionLog();
const [currentHost] = useState<SSHHost | null>(initialHost || null);
const [currentPath, setCurrentPath] = useState(
initialPath || initialHost?.defaultPath || "/",
);
const currentPathRef = useRef(currentPath);
currentPathRef.current = currentPath;
const lastSuccessfulPathRef = useRef(
initialPath || initialHost?.defaultPath || "/",
);
@@ -115,8 +139,13 @@ function FileManagerContent({
]);
const [navIndex, setNavIndex] = useState(0);
const [files, setFiles] = useState<FileItem[]>([]);
const filesRef = useRef(files);
filesRef.current = files;
const directoryRequestRef = useRef(0);
const [isLoading, setIsLoading] = useState(false);
const [sshSessionId, setSshSessionId] = useState<string | null>(null);
const sshSessionIdRef = useRef(sshSessionId);
sshSessionIdRef.current = sshSessionId;
const [isReconnecting, setIsReconnecting] = useState<boolean>(false);
const [searchQuery, setSearchQuery] = useState("");
const [lastRefreshTime, setLastRefreshTime] = useState<number>(0);
@@ -124,6 +153,16 @@ function FileManagerContent({
const saved = localStorage.getItem("fileManagerViewMode");
return saved === "grid" || saved === "list" ? saved : "grid";
});
// Picking an interface preset seeds this key from another part of the app.
useEffect(() => {
const handler = () => {
const saved = localStorage.getItem("fileManagerViewMode");
if (saved === "grid" || saved === "list") setViewMode(saved);
};
window.addEventListener("fileManagerViewModeChanged", handler);
return () =>
window.removeEventListener("fileManagerViewModeChanged", handler);
}, []);
const [sortBy, setSortBy] = useState<"name" | "modified" | "size">(() => {
const saved = localStorage.getItem("fileManagerSortBy");
return saved === "name" || saved === "modified" || saved === "size"
@@ -150,6 +189,7 @@ function FileManagerContent({
const [showPassphraseDialog, setShowPassphraseDialog] = useState(false);
const [pinnedFiles, setPinnedFiles] = useState<Set<string>>(new Set());
const [sidebarRefreshTrigger, setSidebarRefreshTrigger] = useState(0);
const [trashOpen, setTrashOpen] = useState(false);
const [hasConnectionError, setHasConnectionError] = useState<boolean>(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [diskInfo, setDiskInfo] = useState<{
@@ -214,6 +254,32 @@ function FileManagerContent({
const { selectedFiles, clearSelection, setSelection } = useFileSelection();
const commitFilesForPath = useCallback(
(sessionId: string, path: string, next: FileItem[]) => {
updateCachedFileList(sessionId, path, next);
if (
sshSessionIdRef.current !== sessionId ||
currentPathRef.current !== path
) {
return;
}
directoryRequestRef.current += 1;
filesRef.current = next;
setFiles(next);
},
[],
);
const filesForPath = useCallback((sessionId: string, path: string) => {
if (
sshSessionIdRef.current === sessionId &&
currentPathRef.current === path
) {
return filesRef.current;
}
return peekCachedFileList(sessionId, path)?.files ?? [];
}, []);
const { dragHandlers } = useDragAndDrop({
onFilesDropped: handleFilesDropped,
onItemsDropped: handleItemsDropped,
@@ -264,13 +330,31 @@ function FileManagerContent({
stage: "connection",
message: errorMessage,
});
connectRetryRef.current?.markFailed();
},
[addLog],
);
const connectRetry = useConnectionRetry({
connect: () => {
void initializeSSHConnection();
},
enabled:
!!currentHost &&
!sshSessionId &&
!totpRequired &&
!warpgateRequired &&
!showAuthDialog &&
!showPassphraseDialog,
autoStart: false,
});
const connectRetryRef = useRef(connectRetry);
connectRetryRef.current = connectRetry;
useEffect(() => {
if (currentHost) {
initializeSSHConnection();
connectRetryRef.current.reset();
connectRetryRef.current.retryNow();
}
}, [currentHost]);
@@ -427,8 +511,8 @@ function FileManagerContent({
setHasConnectionError(true);
addLog({
type: "error",
stage: "error",
message: t("fileManager.sshRequiredForFileManager"),
timestamp: new Date().toISOString(),
});
setIsLoading(false);
return;
@@ -496,6 +580,7 @@ function FileManagerContent({
}
setSshSessionId(sessionId);
connectRetryRef.current.markConnected();
try {
const response = await listSSHFiles(sessionId, currentPath);
@@ -563,7 +648,7 @@ function FileManagerContent({
handleCloseWithError(
t("fileManager.failedToConnect") +
": " +
(error instanceof Error ? error.message : String(error)),
getErrorMessage(error, String(error)),
);
} finally {
setIsLoading(false);
@@ -577,6 +662,7 @@ function FileManagerContent({
console.error("Cannot load directory: no SSH session ID");
return false;
}
const requestId = ++directoryRequestRef.current;
let resolvedPath = path;
if (path.includes("$") || path.startsWith("~")) {
@@ -588,12 +674,19 @@ function FileManagerContent({
}
currentLoadingPathRef.current = resolvedPath;
setIsLoading(true);
const cached = peekCachedFileList(sshSessionId, resolvedPath);
if (cached) {
setFiles(cached.files);
clearSelection();
}
setIsLoading(!cached);
try {
const response = await listSSHFiles(sshSessionId, resolvedPath);
const response = await listSSHFiles(sshSessionId, resolvedPath, {
force: cached !== null,
});
if (currentLoadingPathRef.current !== resolvedPath) {
if (directoryRequestRef.current !== requestId) {
return false;
}
@@ -606,7 +699,7 @@ function FileManagerContent({
clearSelection();
return true;
} catch (error: unknown) {
if (currentLoadingPathRef.current === resolvedPath) {
if (directoryRequestRef.current === requestId) {
// ApiError has .status directly; raw axios errors have .response.status
const apiError = error as {
status?: number;
@@ -717,7 +810,7 @@ function FileManagerContent({
}
return false;
} finally {
if (currentLoadingPathRef.current === resolvedPath) {
if (directoryRequestRef.current === requestId) {
setIsLoading(false);
currentLoadingPathRef.current = "";
}
@@ -744,7 +837,12 @@ function FileManagerContent({
const navigateTo = useCallback(
(path: string) => {
if (sshSessionId) setIsLoading(true);
directoryRequestRef.current += 1;
const cached = sshSessionId
? peekCachedFileList(sshSessionId, path)
: null;
if (cached) setFiles(cached.files);
setIsLoading(!!sshSessionId && !cached);
setCurrentPath(path);
setNavHistory((prev) => {
const next = [...prev.slice(0, navIndex + 1), path];
@@ -757,19 +855,31 @@ function FileManagerContent({
const goBack = useCallback(() => {
if (navIndex > 0) {
if (sshSessionId) setIsLoading(true);
directoryRequestRef.current += 1;
const newIndex = navIndex - 1;
const path = navHistory[newIndex];
const cached = sshSessionId
? peekCachedFileList(sshSessionId, path)
: null;
if (cached) setFiles(cached.files);
setIsLoading(!!sshSessionId && !cached);
setNavIndex(newIndex);
setCurrentPath(navHistory[newIndex]);
setCurrentPath(path);
}
}, [navIndex, navHistory, sshSessionId]);
const goForward = useCallback(() => {
if (navIndex < navHistory.length - 1) {
if (sshSessionId) setIsLoading(true);
directoryRequestRef.current += 1;
const newIndex = navIndex + 1;
const path = navHistory[newIndex];
const cached = sshSessionId
? peekCachedFileList(sshSessionId, path)
: null;
if (cached) setFiles(cached.files);
setIsLoading(!!sshSessionId && !cached);
setNavIndex(newIndex);
setCurrentPath(navHistory[newIndex]);
setCurrentPath(path);
}
}, [navIndex, navHistory, sshSessionId]);
@@ -807,11 +917,12 @@ function FileManagerContent({
}
setLastRefreshTime(now);
if (sshSessionId) invalidateCachedFileList(sshSessionId, currentPath);
// Force reset loading state to ensure refresh is not blocked
setIsLoading(false);
currentLoadingPathRef.current = "";
loadDirectory(currentPath);
}, [currentPath, lastRefreshTime, loadDirectory]);
}, [currentPath, lastRefreshTime, loadDirectory, sshSessionId]);
useEffect(() => {
const handler = (event: Event) => {
@@ -850,7 +961,7 @@ function FileManagerContent({
activeElement &&
(activeElement.tagName === "INPUT" ||
activeElement.tagName === "TEXTAREA" ||
activeElement.contentEditable === "true")
(activeElement as HTMLElement).contentEditable === "true")
) {
return;
}
@@ -1110,11 +1221,24 @@ function FileManagerContent({
});
}
const fullMessage = `${confirmMessage}\n\n${t("fileManager.permanentDeleteWarning")}`;
const fullMessage = `${confirmMessage}\n\n${t("fileManager.moveToTrashWarning")}`;
confirmWithToast(
fullMessage,
async () => {
const operationPath = currentPath;
const operationSession = sshSessionId;
const deletedPaths = new Set(files.map((file) => file.path));
commitFilesForPath(
operationSession,
operationPath,
removePaths(
filesForPath(operationSession, operationPath),
deletedPaths,
),
);
clearSelection();
let completed = 0;
try {
await ensureSSHConnection();
@@ -1126,31 +1250,29 @@ function FileManagerContent({
currentHost?.id,
currentHost?.userId?.toString(),
);
completed += 1;
}
const deletedFiles = files.map((file) => ({
path: file.path,
name: file.name,
}));
const undoAction: UndoAction = {
type: "delete",
description: t("fileManager.deletedItems", { count: files.length }),
data: {
operation: "cut",
deletedFiles,
targetDirectory: currentPath,
},
timestamp: Date.now(),
};
setUndoHistory((prev) => [...prev.slice(-9), undoAction]);
toast.success(
t("fileManager.itemsDeletedSuccessfully", { count: files.length }),
t("fileManager.itemsMovedToTrash", { count: files.length }),
);
handleRefreshDirectory();
clearSelection();
if (
sshSessionIdRef.current === operationSession &&
currentPathRef.current === operationPath
) {
handleRefreshDirectory();
} else {
invalidateCachedFileList(operationSession, operationPath);
}
} catch (error: unknown) {
commitFilesForPath(
operationSession,
operationPath,
restoreItems(
filesForPath(operationSession, operationPath),
files.slice(completed),
),
);
const axiosError = error as {
response?: {
data?: { needsSudo?: boolean; error?: string };
@@ -1159,10 +1281,36 @@ function FileManagerContent({
message?: string;
};
if (axiosError.response?.data?.needsSudo) {
setPendingSudoOperation({ type: "delete", files });
setPendingSudoOperation({
type: "delete",
files: files.slice(completed),
});
setSudoDialogOpen(true);
return;
}
if (
(axiosError.response?.data as { trashUnavailable?: boolean })
?.trashUnavailable &&
window.confirm(t("fileManager.trashUnavailableConfirm"))
) {
for (const file of files.slice(completed)) {
await deleteSSHItem(
sshSessionId,
file.path,
file.type === "directory",
currentHost?.id,
currentHost?.userId?.toString(),
true,
);
}
toast.success(
t("fileManager.itemsDeletedSuccessfully", {
count: files.length - completed,
}),
);
handleRefreshDirectory();
return;
}
if (
axiosError.response?.status === 403 ||
axiosError.response?.data?.error
@@ -1339,9 +1487,10 @@ function FileManagerContent({
});
}
} catch (error: unknown) {
const httpError = asHttpError(error);
toast.error(
error?.response?.data?.error ||
error?.message ||
httpError.response?.data?.error ||
httpError.message ||
t("fileManager.failedToResolveSymlink"),
);
}
@@ -1403,7 +1552,13 @@ function FileManagerContent({
async function handleFileOpen(file: FileItem) {
if (file.type === "directory") {
if (sshSessionId) setIsLoading(true);
markAdaptiveResourceUsed("network", "directory-list");
directoryRequestRef.current += 1;
const cached = sshSessionId
? peekCachedFileList(sshSessionId, file.path)
: null;
if (cached) setFiles(cached.files);
setIsLoading(!!sshSessionId && !cached);
setCurrentPath(file.path);
return;
}
@@ -1420,10 +1575,42 @@ function FileManagerContent({
if (!(await confirmLargeFileOpen(file))) return;
markFilePreviewUsed(file.name);
markAdaptiveResourceUsed(
"network",
file.size && file.size > 128 * 1024
? "file-content:medium"
: "file-content:small",
);
await recordRecentFile(file);
openFileWindow(file, sshSessionId);
}
const prefetchFileIntent = useCallback(
(file: FileItem) => {
if (!sshSessionId) return;
if (file.type === "directory") {
runAdaptiveBackgroundTask("network", "directory-list", () =>
listSSHFiles(sshSessionId, file.path),
);
return;
}
const budget = getAdaptiveResourceBudget("network");
if (shouldPrefetchFileContent(file, budget.maxPrefetchBytes)) {
preloadFilePreview(file.name);
runAdaptiveBackgroundTask(
"network",
file.size && file.size > 128 * 1024
? "file-content:medium"
: "file-content:small",
() => readSSHFile(sshSessionId, file.path),
{ estimatedBytes: file.size },
);
}
},
[sshSessionId],
);
function handleContextMenu(event: React.MouseEvent, file?: FileItem) {
event.preventDefault();
@@ -1637,7 +1824,7 @@ function FileManagerContent({
? t("fileManager.copy")
: t("fileManager.move"),
name: file.name,
error: error instanceof Error ? error.message : String(error),
error: getErrorMessage(error, String(error)),
}),
);
}
@@ -1731,8 +1918,7 @@ function FileManagerContent({
setClipboard(null);
}
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : String(error);
const errorMessage = getErrorMessage(error, String(error));
toast.error(`${t("fileManager.pasteFailed")}: ${errorMessage}`);
}
}
@@ -1756,11 +1942,9 @@ function FileManagerContent({
toast.success(
t("fileManager.archiveExtractedSuccessfully", { name: file.name }),
);
handleRefreshDirectory();
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : String(error);
const errorMessage = getErrorMessage(error, String(error));
toast.error(`${t("fileManager.extractFailed")}: ${errorMessage}`);
}
}
@@ -1799,12 +1983,10 @@ function FileManagerContent({
name: archiveName,
}),
);
handleRefreshDirectory();
clearSelection();
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : String(error);
const errorMessage = getErrorMessage(error, String(error));
toast.error(`${t("fileManager.compressFailed")}: ${errorMessage}`);
}
}
@@ -1821,7 +2003,7 @@ function FileManagerContent({
destPath: string,
destPathLabel: string,
methodPreference: TransferMethodPreference,
parallelSegmentCount: number,
parallelSegmentCount?: number,
) {
if (!sshSessionId || !currentHost?.id || transferFiles.length === 0) return;
@@ -1915,8 +2097,7 @@ function FileManagerContent({
toast.error(
t("fileManager.deleteCopiedFileFailed", {
name: copiedFile.targetName,
error:
error instanceof Error ? error.message : String(error),
error: getErrorMessage(error, String(error)),
}),
);
}
@@ -1958,8 +2139,7 @@ function FileManagerContent({
toast.error(
t("fileManager.moveBackFileFailed", {
name: movedFile.targetName,
error:
error instanceof Error ? error.message : String(error),
error: getErrorMessage(error, String(error)),
}),
);
}
@@ -1992,8 +2172,7 @@ function FileManagerContent({
handleRefreshDirectory();
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : String(error);
const errorMessage = getErrorMessage(error, String(error));
toast.error(`${t("fileManager.undoOperationFailed")}: ${errorMessage}`);
console.error("Undo failed:", error);
}
@@ -2062,10 +2241,25 @@ function FileManagerContent({
async function handleConfirmCreate(name: string) {
if (!createIntent || !sshSessionId) return;
const operationPath = currentPath;
const operationSession = sshSessionId;
const optimisticPath = childPath(operationPath, name);
const previousFiles = filesForPath(operationSession, operationPath);
const optimisticFiles = addOptimisticItem(
previousFiles,
operationPath,
name,
createIntent.type,
);
const didAddOptimistically = optimisticFiles !== previousFiles;
commitFilesForPath(operationSession, operationPath, optimisticFiles);
const createdType = createIntent.type;
setCreateIntent(null);
try {
await ensureSSHConnection();
if (createIntent.type === "file") {
if (createdType === "file") {
await createSSHFile(
sshSessionId,
currentPath,
@@ -2086,9 +2280,25 @@ function FileManagerContent({
toast.success(t("fileManager.folderCreatedSuccessfully", { name }));
}
setCreateIntent(null);
handleRefreshDirectory();
if (
sshSessionIdRef.current === operationSession &&
currentPathRef.current === operationPath
) {
handleRefreshDirectory();
} else {
invalidateCachedFileList(operationSession, operationPath);
}
} catch (error: unknown) {
if (didAddOptimistically) {
commitFilesForPath(
operationSession,
operationPath,
removePaths(
filesForPath(operationSession, operationPath),
new Set([optimisticPath]),
),
);
}
const axiosError = error as {
response?: { status?: number; data?: { error?: string } };
};
@@ -2113,6 +2323,25 @@ function FileManagerContent({
async function handleRenameConfirm(file: FileItem, newName: string) {
if (!sshSessionId) return;
const operationPath = currentPath;
const operationSession = sshSessionId;
const renamedPath = childPath(
file.path.slice(0, Math.max(1, file.path.lastIndexOf("/"))),
newName,
);
const operationFiles = filesForPath(operationSession, operationPath);
const didRenameOptimistically = !operationFiles.some(
(entry) => entry.path === renamedPath && entry.path !== file.path,
);
if (didRenameOptimistically) {
commitFilesForPath(
operationSession,
operationPath,
renameOptimisticItem(operationFiles, file.path, newName),
);
}
setEditingFile(null);
try {
await ensureSSHConnection();
@@ -2127,9 +2356,26 @@ function FileManagerContent({
toast.success(
t("fileManager.itemRenamedSuccessfully", { name: newName }),
);
setEditingFile(null);
handleRefreshDirectory();
if (
sshSessionIdRef.current === operationSession &&
currentPathRef.current === operationPath
) {
handleRefreshDirectory();
} else {
invalidateCachedFileList(operationSession, operationPath);
}
} catch (error: unknown) {
if (didRenameOptimistically) {
commitFilesForPath(
operationSession,
operationPath,
renameOptimisticItem(
filesForPath(operationSession, operationPath),
renamedPath,
file.name,
),
);
}
const axiosError = error as {
response?: { status?: number; data?: { error?: string } };
};
@@ -2167,6 +2413,7 @@ function FileManagerContent({
setTotpPrompt("");
setSshSessionId(totpSessionId);
setTotpSessionId(null);
connectRetryRef.current.markConnected();
try {
const response = await listSSHFiles(totpSessionId, currentPath);
@@ -2211,6 +2458,7 @@ function FileManagerContent({
setWarpgateSecurityKey("");
setSshSessionId(warpgateSessionId);
setWarpgateSessionId(null);
connectRetryRef.current.markConnected();
try {
const response = await listSSHFiles(warpgateSessionId, currentPath);
@@ -2308,6 +2556,7 @@ function FileManagerContent({
}
setSshSessionId(sessionId);
connectRetryRef.current.markConnected();
try {
const response = await listSSHFiles(sessionId, currentPath);
@@ -2329,7 +2578,7 @@ function FileManagerContent({
toast.error(
t("fileManager.failedToConnect") +
": " +
(error instanceof Error ? error.message : String(error)),
getErrorMessage(error, String(error)),
);
} finally {
setIsLoading(false);
@@ -2392,6 +2641,7 @@ function FileManagerContent({
}
setSshSessionId(sessionId);
connectRetryRef.current.markConnected();
try {
const response = await listSSHFiles(sessionId, currentPath);
@@ -2477,7 +2727,7 @@ function FileManagerContent({
toast.error(
t("fileManager.moveFileFailed", { name: file.name }) +
": " +
(error instanceof Error ? error.message : String(error)),
getErrorMessage(error, String(error)),
);
}
}
@@ -2523,7 +2773,7 @@ function FileManagerContent({
toast.error(
t("fileManager.moveOperationFailed") +
": " +
(error instanceof Error ? error.message : String(error)),
getErrorMessage(error, String(error)),
);
}
}
@@ -2556,15 +2806,17 @@ function FileManagerContent({
);
openWindow({
id: windowId,
type: "diff",
title: t("fileManager.fileComparison", {
file1: file1.name,
file2: file2.name,
}),
x: offsetX,
y: offsetY,
width: 800,
height: 600,
isMaximized: false,
isMinimized: false,
component: createWindowComponent,
zIndex: Date.now(),
});
toast.success(
@@ -2598,7 +2850,7 @@ function FileManagerContent({
toast.error(
t("fileManager.dragFailed") +
": " +
(error instanceof Error ? error.message : String(error)),
getErrorMessage(error, String(error)),
);
}
}
@@ -2690,9 +2942,7 @@ function FileManagerContent({
try {
const pinnedData = await getPinnedFiles(currentHost.id);
const pinnedPaths = new Set(
pinnedData.map((item: Record<string, unknown>) => item.path),
);
const pinnedPaths = new Set(pinnedData.map((item) => item.path));
setPinnedFiles(pinnedPaths);
} catch (error) {
console.error("Failed to load pinned files:", error);
@@ -2871,17 +3121,14 @@ function FileManagerContent({
if ((isLoading || isReconnecting) && !sshSessionId) {
return (
<div className="h-full w-full flex flex-col bg-background relative">
<div className="flex-1 overflow-hidden min-h-0 relative">
<SimpleLoader
visible={!isConnectionLogExpanded}
message={t("fileManager.connecting")}
/>
</div>
<ConnectionLog
isConnecting={isLoading || isReconnecting}
isConnected={false}
hasConnectionError={hasConnectionError}
position={hasConnectionError ? "top" : "bottom"}
<ConnectionScreen
status={isReconnecting ? "connecting" : connectRetry.status}
message={t("fileManager.connecting")}
attempt={connectRetry.attempt}
maxAttempts={connectRetry.maxAttempts}
nextRetryInMs={connectRetry.nextRetryInMs}
onManualRetry={connectRetry.retryNow}
logPosition={hasConnectionError ? "top" : "bottom"}
/>
</div>
);
@@ -2889,12 +3136,7 @@ function FileManagerContent({
return (
<div className="h-full flex flex-col bg-background relative overflow-hidden isolate">
<div
className="h-full w-full flex flex-col min-h-0"
style={{
visibility: isConnectionLogExpanded ? "hidden" : "visible",
}}
>
<div className="h-full w-full flex flex-col min-h-0">
<FileManagerToolbar
t={t}
currentPath={currentPath}
@@ -2951,11 +3193,11 @@ function FileManagerContent({
currentHost={currentHost}
currentPath={currentPath}
onPathChange={navigateTo}
onLoadDirectory={loadDirectory}
onFileOpen={handleSidebarFileOpen}
onItemContextMenu={handleSidebarItemContextMenu}
sshSessionId={sshSessionId}
refreshTrigger={sidebarRefreshTrigger}
onOpenTrash={() => setTrashOpen(true)}
diskInfo={diskInfo ?? undefined}
/>
</div>
@@ -2967,6 +3209,7 @@ function FileManagerContent({
files={filteredFiles}
selectedFiles={selectedFiles}
onFileOpen={handleFileOpen}
onFileIntent={prefetchFileIntent}
onSelectionChange={setSelection}
onRefresh={handleRefreshDirectory}
onUpload={handleFilesDropped}
@@ -3059,6 +3302,17 @@ function FileManagerContent({
</div>
</div>
</div>
<FileManagerTrashDialog
open={trashOpen}
onOpenChange={setTrashOpen}
sessionId={sshSessionId}
onChanged={() => {
if (sshSessionId)
invalidateCachedFileList(sshSessionId, currentPath);
void handleRefreshDirectory();
}}
/>
</div>
{currentHost && (
@@ -3115,9 +3369,9 @@ function FileManagerContent({
setPendingSudoOperation={setPendingSudoOperation}
handleSudoPasswordSubmit={handleSudoPasswordSubmit}
/>
<ConnectionLog
<ConnectionLogPanel
isConnecting={isReconnecting || isLoading}
isConnected={!!sshSessionId}
isConnected={!!sshSessionId && !isReconnecting}
hasConnectionError={hasConnectionError}
position={hasConnectionError ? "top" : "bottom"}
/>
+13 -14
View File
@@ -2,6 +2,7 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { FileManager } from "@/features/file-manager/FileManager.tsx";
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
interface FileManagerAppProps {
hostId?: string;
@@ -15,33 +16,31 @@ const FileManagerApp: React.FC<FileManagerAppProps> = ({
const { t } = useTranslation();
return (
<FullScreenAppWrapper hostId={hostId}>
{(hostConfig, loading) => {
if (loading) {
{(hostConfig, phase) => {
if (phase === "loading") {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
<p className="text-muted-foreground">
{t("hosts.loadingHost")}
</p>
</div>
<div className="relative h-full w-full">
<ConnectionScreen
status="connecting"
message={t("hosts.loadingHost")}
/>
</div>
);
}
if (!hostConfig) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-red-500 mb-4">{t("hosts.hostNotFound")}</p>
</div>
<div className="relative h-full w-full">
<ConnectionScreen
status="disconnected"
message={t("hosts.hostNotFound")}
/>
</div>
);
}
return (
<FileManager
embedded={true}
initialHost={hostConfig}
initialPath={initialPath}
onClose={() => {}}
@@ -32,10 +32,7 @@ type FileManagerDialogsProps = {
handleAuthDialogCancel: () => void;
permissionsDialogFile: FileItem | null;
setPermissionsDialogFile: (file: FileItem | null) => void;
handleSavePermissions: (
file: FileItem,
permissions: string,
) => void | Promise<void>;
handleSavePermissions: (file: FileItem, permissions: string) => Promise<void>;
sudoDialogOpen: boolean;
setSudoDialogOpen: (open: boolean) => void;
setPendingSudoOperation: (operation: PendingSudoOperation | null) => void;
@@ -46,6 +46,7 @@ interface FileManagerGridProps {
files: FileItem[];
selectedFiles: FileItem[];
onFileOpen: (file: FileItem) => void;
onFileIntent?: (file: FileItem) => void;
onSelectionChange: (files: FileItem[]) => void;
onRefresh: () => void;
onUpload?: (files: FileList) => void;
@@ -162,6 +163,7 @@ export function FileManagerGrid({
files,
selectedFiles,
onFileOpen,
onFileIntent,
onSelectionChange,
onRefresh,
onUpload,
@@ -715,6 +717,8 @@ export function FileManagerGrid({
const handleFileClick = (file: FileItem, event: React.MouseEvent) => {
event.stopPropagation();
onFileIntent?.(file);
if (gridRef.current && !createIntent) {
gridRef.current.focus();
}
@@ -782,7 +786,7 @@ export function FileManagerGrid({
activeElement &&
(activeElement.tagName === "INPUT" ||
activeElement.tagName === "TEXTAREA" ||
activeElement.contentEditable === "true")
(activeElement as HTMLElement).contentEditable === "true")
) {
return;
}
@@ -12,6 +12,7 @@ import {
Bookmark,
File,
Folder,
Trash2,
ChevronDown,
Check,
} from "lucide-react";
@@ -86,6 +87,7 @@ interface FileManagerSidebarProps {
onItemContextMenu?: (event: React.MouseEvent, item: SidebarItem) => void;
sshSessionId?: string;
refreshTrigger?: number;
onOpenTrash?: () => void;
diskInfo?: {
usedHuman: string;
totalHuman: string;
@@ -184,6 +186,7 @@ export function FileManagerSidebar({
onItemContextMenu,
sshSessionId,
refreshTrigger,
onOpenTrash,
diskInfo,
}: FileManagerSidebarProps) {
const { t } = useTranslation();
@@ -227,7 +230,7 @@ export function FileManagerSidebar({
try {
const recentData = await getRecentFiles(currentHost.id);
const recentItems = (recentData as RecentFileData[])
const recentItems = (recentData as unknown as RecentFileData[])
.slice(0, 5)
.map((item: RecentFileData) => ({
id: `recent-${item.id}`,
@@ -239,7 +242,7 @@ export function FileManagerSidebar({
setRecentItems(recentItems);
const pinnedData = await getPinnedFiles(currentHost.id);
const pinnedItems = (pinnedData as PinnedFileData[]).map(
const pinnedItems = (pinnedData as unknown as PinnedFileData[]).map(
(item: PinnedFileData) => ({
id: `pinned-${item.id}`,
name: item.name,
@@ -250,7 +253,7 @@ export function FileManagerSidebar({
setPinnedItems(pinnedItems);
const shortcutData = await getFolderShortcuts(currentHost.id);
const shortcutItems = (shortcutData as ShortcutData[]).map(
const shortcutItems = (shortcutData as unknown as ShortcutData[]).map(
(item: ShortcutData) => ({
id: `shortcut-${item.id}`,
name: item.name,
@@ -717,6 +720,15 @@ export function FileManagerSidebar({
<>
<div className="h-full flex flex-col bg-card overflow-hidden">
<div className="flex-1 overflow-y-auto thin-scrollbar">
{onOpenTrash && (
<button
className="w-full flex items-center gap-2.5 px-3 py-2 text-[11px] font-bold uppercase tracking-wider text-muted-foreground hover:text-foreground hover:bg-muted border-l-2 border-transparent"
onClick={onOpenTrash}
>
<Trash2 className="size-3.5 shrink-0" />
<span>{t("fileManager.trash")}</span>
</button>
)}
{/* ── Recent files ──────────────────────────────────────── */}
{renderSection(t("fileManager.recent"), recentItems, (item) =>
renderQuickAccessItem(
@@ -0,0 +1,214 @@
import { useCallback, useEffect, useState } from "react";
import { RotateCcw, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Button } from "@/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import {
emptySSHTrash,
getSSHTrash,
permanentlyDeleteSSHTrashItem,
restoreSSHTrashItem,
updateSSHTrashRetention,
type TrashItem,
} from "@/api/ssh-file-operations-api";
export function FileManagerTrashDialog({
open,
onOpenChange,
sessionId,
onChanged,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
sessionId: string | null;
onChanged: () => void;
}) {
const { t } = useTranslation();
const [items, setItems] = useState<TrashItem[]>([]);
const [retentionDays, setRetentionDays] = useState(7);
const [canManageRetention, setCanManageRetention] = useState(false);
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
if (!sessionId) return;
setLoading(true);
try {
const result = await getSSHTrash(sessionId);
setItems(result.items);
setRetentionDays(result.retentionDays);
setCanManageRetention(result.canManageRetention);
} catch {
toast.error(t("fileManager.trashLoadFailed"));
} finally {
setLoading(false);
}
}, [sessionId, t]);
useEffect(() => {
if (open) void load();
}, [load, open]);
async function restore(item: TrashItem) {
if (!sessionId) return;
try {
await restoreSSHTrashItem(sessionId, item.id);
setItems((current) => current.filter((entry) => entry.id !== item.id));
onChanged();
toast.success(t("fileManager.trashRestored", { name: item.name }));
} catch {
toast.error(t("fileManager.trashRestoreFailed"));
}
}
async function remove(item: TrashItem) {
if (!sessionId) return;
if (
!window.confirm(t("fileManager.trashDeleteConfirm", { name: item.name }))
)
return;
try {
await permanentlyDeleteSSHTrashItem(sessionId, item.id);
setItems((current) => current.filter((entry) => entry.id !== item.id));
toast.success(
t("fileManager.trashPermanentlyDeleted", { name: item.name }),
);
} catch {
toast.error(t("fileManager.trashDeleteFailed"));
}
}
async function empty() {
if (!sessionId || items.length === 0) return;
if (!window.confirm(t("fileManager.trashEmptyConfirm"))) return;
try {
await emptySSHTrash(sessionId);
setItems([]);
toast.success(t("fileManager.trashEmptied"));
} catch {
toast.error(t("fileManager.trashDeleteFailed"));
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[85dvh] w-[calc(100vw-2rem)] flex-col sm:max-w-3xl">
<DialogHeader className="shrink-0 pr-8">
<DialogTitle>{t("fileManager.trash")}</DialogTitle>
<DialogDescription>
{t("fileManager.trashDescription", { days: retentionDays })}{" "}
<a
href="https://docs.termix.site/features/files-and-hosts/trash"
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
</DialogDescription>
</DialogHeader>
{canManageRetention && (
<div className="flex shrink-0 items-center gap-2 border border-border p-2 text-xs">
<span className="flex-1">{t("fileManager.trashRetention")}</span>
<input
type="number"
min={1}
max={3650}
value={retentionDays}
onChange={(event) => setRetentionDays(Number(event.target.value))}
className="h-8 w-20 border border-border bg-background px-2"
/>
<Button
size="sm"
variant="outline"
onClick={async () => {
if (!sessionId || retentionDays < 1 || retentionDays > 3650)
return;
try {
await updateSSHTrashRetention(sessionId, retentionDays);
toast.success(t("fileManager.trashRetentionSaved"));
} catch {
toast.error(t("fileManager.trashRetentionFailed"));
}
}}
>
{t("common.save")}
</Button>
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto border border-border">
{loading ? (
<div className="p-6 text-center text-sm text-muted-foreground">
{t("common.loading")}
</div>
) : items.length === 0 ? (
<div className="p-6 text-center text-sm text-muted-foreground">
{t("fileManager.trashEmpty")}
</div>
) : (
items.map((item) => (
<div
key={item.id}
className="flex flex-col gap-2 border-b border-border p-3 last:border-b-0 sm:flex-row sm:items-center sm:gap-3"
>
<Trash2 className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div
className="truncate text-sm font-medium"
title={item.name}
>
{item.name}
</div>
<div
className="truncate text-xs text-muted-foreground"
title={item.originalPath}
>
{item.originalPath}
</div>
<div className="text-xs text-muted-foreground">
{new Date(item.deletedAt).toLocaleString()}
</div>
</div>
<div className="flex shrink-0 gap-2">
<Button
size="sm"
variant="outline"
onClick={() => void restore(item)}
>
<RotateCcw className="mr-1 size-3.5" />
{t("fileManager.trashRestore")}
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => void remove(item)}
>
{t("fileManager.trashDeletePermanently")}
</Button>
</div>
</div>
))
)}
</div>
<div className="flex shrink-0 justify-end">
<Button
variant="destructive"
disabled={items.length === 0}
onClick={() => void empty()}
>
{t("fileManager.trashEmptyAction")}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -12,6 +12,7 @@ import {
getPendingTransferIds,
isTransferNotified,
} from "./transferNotificationStore.ts";
import { runAdaptivePolling } from "@/lib/adaptive-polling.ts";
const POLL_INTERVAL_MS = 2000;
@@ -24,8 +25,10 @@ export function TransferMonitor() {
useEffect(() => {
const reconcileTransfers = async () => {
let hasWork = false;
try {
const { transfers } = await listActiveTransfers();
hasWork = transfers.length > 0;
for (const transfer of transfers) {
if (isTransferBeingMonitored(transfer.transferId)) continue;
beginTransferProgressMonitoring(transfer.transferId, t, {
@@ -38,13 +41,15 @@ export function TransferMonitor() {
// Non-fatal: file-manager service may be unavailable briefly
}
for (const transferId of getPendingTransferIds()) {
const pendingTransferIds = getPendingTransferIds();
for (const transferId of pendingTransferIds) {
if (
isTransferBeingMonitored(transferId) ||
isTransferNotified(transferId)
) {
continue;
}
hasWork = true;
try {
const status = await getTransferStatus(transferId);
@@ -69,36 +74,14 @@ export function TransferMonitor() {
clearStalePendingTransfer(transferId);
}
}
return hasWork;
};
let intervalId: ReturnType<typeof setInterval> | null = null;
const start = () => {
if (intervalId !== null) return;
intervalId = setInterval(reconcileTransfers, POLL_INTERVAL_MS);
};
const stop = () => {
if (intervalId === null) return;
clearInterval(intervalId);
intervalId = null;
};
const onVisibility = () => {
if (document.visibilityState === "hidden") {
stop();
return;
}
void reconcileTransfers();
start();
};
void reconcileTransfers();
if (document.visibilityState !== "hidden") start();
document.addEventListener("visibilitychange", onVisibility);
return () => {
stop();
document.removeEventListener("visibilitychange", onVisibility);
};
return runAdaptivePolling(reconcileTransfers, {
minIntervalMs: POLL_INTERVAL_MS,
maxIntervalMs: 30_000,
stablePollsPerStep: 1,
});
}, [t, formatTransferMetrics]);
return null;
@@ -85,7 +85,7 @@ export function AudioPreview({
<div className="rounded-lg overflow-hidden">
<AudioPlayer
src={audioUrl}
onLoadedMetadata={() => {
onLoadedMetaData={() => {
onMediaDimensionsChange?.({
width: 600,
height: 400,
@@ -1,7 +1,10 @@
import React, { forwardRef, useImperativeHandle, useMemo, useRef } from "react";
import CodeMirror from "@uiw/react-codemirror";
import { oneDark } from "@codemirror/theme-one-dark";
import { loadLanguage } from "@uiw/codemirror-extensions-langs";
import {
loadLanguage,
type LanguageName,
} from "@uiw/codemirror-extensions-langs";
import { EditorView, keymap } from "@codemirror/view";
import { searchKeymap, search, openSearchPanel } from "@codemirror/search";
import {
@@ -31,7 +34,7 @@ function getLanguageExtension(filename: string) {
const baseName = filename.toLowerCase();
if (["dockerfile", "makefile", "rakefile", "gemfile"].includes(baseName)) {
return loadLanguage(baseName);
return loadLanguage(baseName as LanguageName);
}
const langMap: Record<string, string> = {
@@ -69,7 +72,7 @@ function getLanguageExtension(filename: string) {
};
const language = langMap[ext];
return language ? loadLanguage(language) : null;
return language ? loadLanguage(language as LanguageName) : null;
}
export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(
@@ -154,7 +157,6 @@ export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(
closeBrackets: true,
autocompletion: true,
highlightSelectionMatches: false,
scrollPastEnd: false,
}}
/>
);
@@ -45,32 +45,20 @@ import {
import { Button } from "@/components/button.tsx";
import { Kbd, KbdKey } from "@/components/kbd.tsx";
import type { CodeEditorHandle } from "./CodeEditor.tsx";
import {
loadAudioPreview,
loadCodeEditor,
loadImagePreview,
loadMarkdownRenderer,
loadPdfPreview,
resolveFilePreviewKind,
} from "../file-preview-preload";
const CodeEditor = lazy(() =>
import("./CodeEditor.tsx").then((module) => ({
default: module.CodeEditor,
})),
);
const ImagePreview = lazy(() =>
import("./ImagePreview.tsx").then((module) => ({
default: module.ImagePreview,
})),
);
const MarkdownRenderer = lazy(() =>
import("./MarkdownRenderer.tsx").then((module) => ({
default: module.MarkdownRenderer,
})),
);
const PdfPreview = lazy(() =>
import("./PdfPreview.tsx").then((module) => ({
default: module.PdfPreview,
})),
);
const AudioPreview = lazy(() =>
import("./AudioPreview.tsx").then((module) => ({
default: module.AudioPreview,
})),
);
const CodeEditor = lazy(loadCodeEditor);
const ImagePreview = lazy(loadImagePreview);
const MarkdownRenderer = lazy(loadMarkdownRenderer);
const PdfPreview = lazy(loadPdfPreview);
const AudioPreview = lazy(loadAudioPreview);
interface FileItem {
name: string;
@@ -157,84 +145,45 @@ function getFileType(filename: string): {
icon: React.ReactNode;
color: string;
} {
const ext = filename.split(".").pop()?.toLowerCase() || "";
const kind = resolveFilePreviewKind(filename);
const imageExts = ["png", "jpg", "jpeg", "gif", "bmp", "svg", "webp"];
const videoExts = ["mp4", "avi", "mkv", "mov", "wmv", "flv", "webm"];
const audioExts = ["mp3", "wav", "flac", "ogg", "aac", "m4a"];
const textExts = ["txt", "readme"];
const markdownExts = ["md", "markdown", "mdown", "mkdn", "mdx"];
const pdfExts = ["pdf"];
const codeExts = [
"js",
"ts",
"jsx",
"tsx",
"py",
"java",
"cpp",
"c",
"cs",
"php",
"rb",
"go",
"rs",
"html",
"css",
"scss",
"less",
"json",
"xml",
"yaml",
"yml",
"toml",
"ini",
"conf",
"sh",
"bash",
"zsh",
"sql",
"vue",
"svelte",
];
if (imageExts.includes(ext)) {
if (kind === "image") {
return {
type: "image",
icon: <ImageIcon className="w-6 h-6" />,
color: "text-green-500",
};
} else if (videoExts.includes(ext)) {
} else if (kind === "video") {
return {
type: "video",
icon: <Film className="w-6 h-6" />,
color: "text-purple-500",
};
} else if (audioExts.includes(ext)) {
} else if (kind === "audio") {
return {
type: "audio",
icon: <Music className="w-6 h-6" />,
color: "text-pink-500",
};
} else if (markdownExts.includes(ext)) {
} else if (kind === "markdown") {
return {
type: "markdown",
icon: <FileText className="w-6 h-6" />,
color: "text-blue-600",
};
} else if (pdfExts.includes(ext)) {
} else if (kind === "pdf") {
return {
type: "pdf",
icon: <FileText className="w-6 h-6" />,
color: "text-red-600",
};
} else if (textExts.includes(ext)) {
} else if (kind === "text") {
return {
type: "text",
icon: <FileText className="w-6 h-6" />,
color: "text-blue-500",
};
} else if (codeExts.includes(ext)) {
} else if (kind === "code") {
return {
type: "code",
icon: getLanguageIcon(filename),
@@ -11,6 +11,7 @@ import {
connectSSH,
} from "@/main-axios.ts";
import { toast } from "sonner";
import type { SSHHost } from "@/types/index";
import { useTranslation } from "react-i18next";
interface FileItem {
@@ -24,20 +25,6 @@ interface FileItem {
group?: string;
}
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
password?: string;
key?: string;
keyPassword?: string;
authType: "password" | "key";
credentialId?: number;
userId?: number;
}
interface FileWindowProps {
windowId: string;
file: FileItem;
@@ -284,7 +271,9 @@ export function FileWindow({
await ensureSSHConnection();
const response = await readSSHFile(sshSessionId, file.path);
const response = await readSSHFile(sshSessionId, file.path, {
force: true,
});
const fileContent = response.content || "";
setContent(fileContent);
setPendingContent("");
@@ -51,8 +51,11 @@ export function MarkdownRenderer({
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ inline, className, children, ...props }) {
code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className || "");
// v9 removed the `inline` flag; a fenced block always carries a
// language- class, an inline span never does.
const inline = !className;
return !inline && match ? (
<SyntaxHighlighter
style={syntaxTheme}
@@ -41,7 +41,7 @@ export function PdfPreview({
>
{t("fileManager.previous")}
</Button>
<span className="text-sm text-foreground px-3 py-1 bg-background rounded border">
<span className="text-sm text-foreground px-3 py-1 bg-background border border-border">
{t("fileManager.pageXOfY", {
current: pageNumber,
total: numPages || 0,
@@ -66,7 +66,7 @@ export function PdfPreview({
>
{t("fileManager.zoomOut")}
</Button>
<span className="text-sm text-foreground px-3 py-1 bg-background rounded border min-w-[80px] text-center">
<span className="text-sm text-foreground px-3 py-1 bg-background border border-border min-w-[80px] text-center">
{Math.round(pdfScale * 100)}%
</span>
<Button
@@ -49,6 +49,10 @@ export function TransferProgressToast({
let title = t("transfer.progressTransferring");
if (status.phase === "reconnecting") {
title = t("transfer.progressReconnecting");
} else if (status.phase === "benchmarking") {
title = t("transfer.progressBenchmarking");
} else if (status.phase === "verifying") {
title = t("transfer.progressVerifying");
} else if (status.phase === "compressing") {
title = t("transfer.progressCompressing");
} else if (status.phase === "extracting") {
@@ -75,7 +75,7 @@ interface TransferToHostDialogProps {
destPath: string,
destPathLabel: string,
methodPreference: TransferMethodPreference,
parallelSegmentCount: number,
parallelSegmentCount?: number,
) => void;
}
@@ -190,7 +190,7 @@ export function TransferToHostDialog({
const destPathInputRef = useRef<HTMLInputElement>(null);
const [methodPreference, setMethodPreference] =
useState<TransferMethodPreference>("auto");
const [parallelSegmentCount, setParallelSegmentCount] = useState("2");
const [parallelSegmentCount, setParallelSegmentCount] = useState("auto");
const [methodPreview, setMethodPreview] =
useState<TransferMethodPreview | null>(null);
const [methodPreviewLoading, setMethodPreviewLoading] = useState(false);
@@ -664,7 +664,9 @@ export function TransferToHostDialog({
fullPath,
destPath.trim(),
methodPreference,
Math.max(1, Math.min(8, parseInt(parallelSegmentCount, 10) || 2)),
parallelSegmentCount === "auto"
? undefined
: Math.max(1, Math.min(8, parseInt(parallelSegmentCount, 10))),
);
onOpenChange(false);
};
@@ -981,6 +983,7 @@ export function TransferToHostDialog({
}
className="w-full appearance-none px-2.5 py-1.5 text-xs bg-background border border-border text-foreground outline-none focus:ring-1 focus:ring-ring pr-7"
>
<option value="auto">{t("transfer.methodAuto")}</option>
{[1, 2, 3, 4].map((n) => (
<option key={n} value={n.toString()}>
{t("transfer.parallelSegmentsOption", { count: n })}
@@ -0,0 +1,118 @@
import {
markAdaptiveResourceUsed,
runAdaptiveBackgroundTask,
} from "@/lib/adaptive-resource-budget";
export type FilePreviewKind =
| "image"
| "video"
| "audio"
| "markdown"
| "pdf"
| "text"
| "code"
| "unknown";
const extensions = {
image: new Set(["png", "jpg", "jpeg", "gif", "bmp", "svg", "webp"]),
video: new Set(["mp4", "avi", "mkv", "mov", "wmv", "flv", "webm"]),
audio: new Set(["mp3", "wav", "flac", "ogg", "aac", "m4a"]),
markdown: new Set(["md", "markdown", "mdown", "mkdn", "mdx"]),
pdf: new Set(["pdf"]),
text: new Set(["txt", "readme"]),
code: new Set([
"js",
"ts",
"jsx",
"tsx",
"py",
"java",
"cpp",
"c",
"cs",
"php",
"rb",
"go",
"rs",
"html",
"css",
"scss",
"less",
"json",
"xml",
"yaml",
"yml",
"toml",
"ini",
"conf",
"sh",
"bash",
"zsh",
"sql",
"vue",
"svelte",
]),
} satisfies Record<Exclude<FilePreviewKind, "unknown">, Set<string>>;
const extensionlessCodeFiles = new Set([
"dockerfile",
"makefile",
"rakefile",
"gemfile",
]);
export function resolveFilePreviewKind(filename: string): FilePreviewKind {
const lowerName = filename.toLowerCase();
if (extensionlessCodeFiles.has(lowerName)) return "code";
const extension = lowerName.split(".").pop() || "";
for (const [kind, knownExtensions] of Object.entries(extensions)) {
if (knownExtensions.has(extension)) return kind as FilePreviewKind;
}
return "unknown";
}
export const loadCodeEditor = () =>
import("./components/CodeEditor.tsx").then((module) => ({
default: module.CodeEditor,
}));
export const loadImagePreview = () =>
import("./components/ImagePreview.tsx").then((module) => ({
default: module.ImagePreview,
}));
export const loadMarkdownRenderer = () =>
import("./components/MarkdownRenderer.tsx").then((module) => ({
default: module.MarkdownRenderer,
}));
export const loadPdfPreview = () =>
import("./components/PdfPreview.tsx").then((module) => ({
default: module.PdfPreview,
}));
export const loadAudioPreview = () =>
import("./components/AudioPreview.tsx").then((module) => ({
default: module.AudioPreview,
}));
const previewLoaders: Partial<Record<FilePreviewKind, () => Promise<unknown>>> =
{
image: loadImagePreview,
audio: loadAudioPreview,
markdown: loadMarkdownRenderer,
pdf: loadPdfPreview,
text: loadCodeEditor,
code: loadCodeEditor,
unknown: loadCodeEditor,
};
export function preloadFilePreview(filename: string): void {
const kind = resolveFilePreviewKind(filename);
const loader = previewLoaders[kind];
if (loader) runAdaptiveBackgroundTask("module", `preview:${kind}`, loader);
}
export function markFilePreviewUsed(filename: string): void {
markAdaptiveResourceUsed(
"module",
`preview:${resolveFilePreviewKind(filename)}`,
);
}
@@ -181,7 +181,7 @@ export function useDragToDesktop({ sshSessionId }: UseDragToDesktopProps) {
const filesData = fileList.map((file, index) => ({
relativePath: file.name,
content: responses[index]?.content || "",
encoding: "base64",
encoding: "base64" as const,
}));
const tempResult = await window.electronAPI.createTempFolder({
@@ -0,0 +1,53 @@
import type { FileItem } from "@/types/index";
export function childPath(parent: string, name: string): string {
return parent === "/" ? `/${name}` : `${parent.replace(/\/$/, "")}/${name}`;
}
export function addOptimisticItem(
files: FileItem[],
parent: string,
name: string,
type: "file" | "directory",
now = Date.now(),
): FileItem[] {
const path = childPath(parent, name);
if (files.some((file) => file.path === path)) return files;
return [
...files,
{
name,
path,
type,
size: type === "file" ? 0 : undefined,
modified: new Date(now).toISOString(),
modifiedTimestamp: now,
},
];
}
export function removePaths(files: FileItem[], paths: Set<string>): FileItem[] {
return files.filter((file) => !paths.has(file.path));
}
export function restoreItems(
files: FileItem[],
restored: FileItem[],
): FileItem[] {
const existing = new Set(files.map((file) => file.path));
return [...files, ...restored.filter((file) => !existing.has(file.path))];
}
export function renameOptimisticItem(
files: FileItem[],
originalPath: string,
newName: string,
): FileItem[] {
const slash = originalPath.lastIndexOf("/");
const parent = slash <= 0 ? "/" : originalPath.slice(0, slash);
return files.map((file) =>
file.path === originalPath
? { ...file, name: newName, path: childPath(parent, newName) }
: file,
);
}
@@ -9,6 +9,17 @@ export function createFormatTransferMetrics(t: TFunction) {
return (timings?: TransferTimings): string => {
if (!timings) return "";
const parts: string[] = [];
if (
timings.directBenchmarkMs !== undefined &&
timings.relayBenchmarkMs !== undefined
) {
parts.push(
t("transfer.metricsRouteBenchmark", {
direct: formatDurationMs(timings.directBenchmarkMs),
relay: formatDurationMs(timings.relayBenchmarkMs),
}),
);
}
if (timings.prepareDestMs !== undefined) {
parts.push(
t("transfer.metricsPrepare", {
@@ -55,6 +66,13 @@ export function createFormatTransferMetrics(t: TFunction) {
}),
);
}
if (timings.verifyMs !== undefined) {
parts.push(
t("transfer.metricsVerify", {
duration: formatDurationMs(timings.verifyMs),
}),
);
}
if (timings.sourceDeleteMs !== undefined) {
parts.push(
t("transfer.metricsSourceDelete", {
@@ -1,3 +1,4 @@
import { getErrorMessage } from "../../lib/error-message.js";
import { toast } from "sonner";
import type { TFunction } from "i18next";
import {
@@ -314,8 +315,7 @@ export function beginTransferProgressMonitoring(
return finalStatus;
})
.catch((error: unknown) => {
const message =
error instanceof Error ? error.message : t("fileManager.unknownError");
const message = getErrorMessage(error, t("fileManager.unknownError"));
toast.error(`${t("transfer.transferError")}: ${message}`, {
id: progressToast,
className: TOAST_CLASS,
+213 -155
View File
@@ -1,3 +1,4 @@
import { getErrorMessage } from "../../lib/error-message.js";
import React, {
useState,
useEffect,
@@ -5,6 +6,7 @@ import React, {
useCallback,
useImperativeHandle,
} from "react";
import type Guacamole from "guacamole-common-js";
import {
GuacamoleDisplay,
type GuacamoleDisplayHandle,
@@ -18,10 +20,11 @@ import {
isElectron,
} from "@/main-axios.ts";
import { readConfiguredDimension } from "@/features/guacamole/guacamole-display-size.ts";
import { parseGuacamoleConfig } from "@/api/guacamole-api";
import { resolveConnectionOrigin } from "@/lib/connection-origin.ts";
import { useTranslation } from "react-i18next";
import { AlertCircle, RefreshCw } from "lucide-react";
import { GuacamoleToolbar } from "@/features/guacamole/GuacamoleToolbar.tsx";
import { GuacamoleFileBrowser } from "@/features/guacamole/GuacamoleFileBrowser.tsx";
import { Button } from "@/components/button.tsx";
import { Input } from "@/components/input.tsx";
import { PasswordInput } from "@/components/password-input.tsx";
@@ -32,9 +35,16 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/dialog.tsx";
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
import {
ConnectionLogProvider,
useConnectionLog,
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
import { useConnectionRetry } from "@/lib/useConnectionRetry.ts";
import { ShareSessionModal } from "@/features/session-sharing/ShareSessionModal.tsx";
import type { SSHHost } from "@/types";
import { useConnectionDefaults } from "@/contexts/ConnectionDefaultsContext";
import { resolveConnectionDefaults } from "@/lib/connection-defaults";
interface GuacamoleAppProps {
hostId?: string;
@@ -53,10 +63,12 @@ export interface GuacamoleAppHandle {
const GuacamoleApp = React.forwardRef<GuacamoleAppHandle, GuacamoleAppProps>(
function GuacamoleApp({ hostId, tabId, protocol, isVisible = true }, ref) {
const { t } = useTranslation();
const defaults = useConnectionDefaults();
const [hostConfig, setHostConfig] = useState<SSHHost | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!defaults.ready) return;
if (!hostId) {
setLoading(false);
return;
@@ -64,50 +76,55 @@ const GuacamoleApp = React.forwardRef<GuacamoleAppHandle, GuacamoleAppProps>(
getSSHHosts()
.then((hosts) => {
const host = hosts.find((h) => h.id === parseInt(hostId, 10));
setHostConfig(host ?? null);
if (!host) {
setHostConfig(null);
return;
}
const connectionType = protocol ?? host.connectionType;
const protocolDefaults = connectionType === "rdp" ? defaults.rdp : {};
setHostConfig({
...host,
guacamoleConfig: resolveConnectionDefaults(
protocolDefaults,
parseGuacamoleConfig(host.guacamoleConfig),
),
});
})
.catch(() => setHostConfig(null))
.finally(() => setLoading(false));
}, [hostId]);
}, [hostId, protocol, defaults.ready, defaults.rdp]);
if (loading) {
return (
<div className="relative w-full h-full">
<SimpleLoader visible={true} message={t("common.loading")} />
<ConnectionScreen status="connecting" 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)" }}
<div className="relative w-full h-full">
<ConnectionScreen
status="disconnected"
message={t("guacamole.hostNotFound")}
/>
<span
className="text-sm font-semibold"
style={{ color: "var(--foreground)" }}
>
{t("guacamole.hostNotFound")}
</span>
</div>
);
}
return (
<GuacamoleAppInner
hostId={parseInt(hostId, 10)}
hostConfig={hostConfig}
hostName={hostConfig.name || hostConfig.ip || String(hostId)}
tabId={tabId}
protocol={protocol}
isVisible={isVisible}
ref={ref}
/>
<ConnectionLogProvider>
<GuacamoleAppInner
hostId={parseInt(hostId, 10)}
hostConfig={hostConfig}
hostName={hostConfig.name || hostConfig.ip || String(hostId)}
tabId={tabId}
protocol={protocol}
isVisible={isVisible}
ref={ref}
/>
</ConnectionLogProvider>
);
},
);
@@ -116,7 +133,7 @@ interface GuacamoleAppInnerProps {
hostId: number;
hostConfig: Pick<
SSHHost,
"connectionType" | "guacamoleConfig" | "rdpAuthType"
"connectionType" | "domain" | "guacamoleConfig" | "rdpAuthType"
>;
hostName: string;
tabId?: string;
@@ -132,6 +149,7 @@ const GuacamoleAppInner = React.forwardRef<
ref,
) {
const { t } = useTranslation();
const { addLog, clearLogs } = useConnectionLog();
const [token, setToken] = useState<string | null>(null);
const [guacamoleConnectionId, setGuacamoleConnectionId] = useState<
string | null
@@ -139,7 +157,7 @@ const GuacamoleAppInner = React.forwardRef<
const [shareModalOpen, setShareModalOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const [connectionError, setConnectionError] = useState<string | null>(null);
const [retryCount, setRetryCount] = useState(0);
const [isDisplayReady, setIsDisplayReady] = useState(false);
const [touchMode, setTouchMode] = useState<GuacamoleTouchMode | null>(() =>
typeof window !== "undefined" &&
(navigator.maxTouchPoints > 0 || "ontouchstart" in window)
@@ -147,6 +165,20 @@ const GuacamoleAppInner = React.forwardRef<
: null,
);
const displayRef = useRef<GuacamoleDisplayHandle>(null);
const [filesystem, setFilesystem] = useState<Guacamole.Object | null>(null);
const [fileBrowserOpen, setFileBrowserOpen] = useState(false);
const [pendingUploads, setPendingUploads] = useState<File[]>([]);
const guacConfig = parseGuacamoleConfig(hostConfig.guacamoleConfig);
const allowUpload = guacConfig.disableUpload !== true;
const allowDownload = guacConfig.disableDownload !== true;
// A dropped file has nowhere to go until the browser is showing the target
// directory, so opening it is part of accepting the drop.
const handleDropFiles = useCallback((files: File[]) => {
setPendingUploads(files);
setFileBrowserOpen(true);
}, []);
const resolvedProtocolForConnect = (protocol ??
hostConfig.connectionType ??
@@ -157,10 +189,12 @@ const GuacamoleAppInner = React.forwardRef<
const [promptedCredentials, setPromptedCredentials] = useState<{
username: string;
password: string;
domain: string;
} | null>(null);
const [promptOpen, setPromptOpen] = useState(needsCredentialPrompt);
const [promptUsername, setPromptUsername] = useState("");
const [promptPassword, setPromptPassword] = useState("");
const [promptDomain, setPromptDomain] = useState(hostConfig.domain ?? "");
useImperativeHandle(ref, () => ({
disconnect: () => displayRef.current?.disconnect(),
@@ -169,79 +203,110 @@ const GuacamoleAppInner = React.forwardRef<
canShare: () => guacamoleConnectionId !== null,
}));
const fetchToken = useCallback(async (): Promise<void> => {
setToken(null);
setGuacamoleConnectionId(null);
setError(null);
if (isElectron()) {
const origin = await resolveConnectionOrigin({
connectionType: resolvedProtocolForConnect,
});
if (origin === "remote") {
const remoteConfig = (await window.electronAPI?.invoke?.(
"get-remote-sync-config",
)) as { serverUrl?: string } | null;
if (!remoteConfig?.serverUrl) {
throw new Error(t("errors.remoteServerRequired"));
}
}
}
addLog({
type: "info",
stage: "guac_guacd",
message: t("guacamole.connecting", {
type: resolvedProtocolForConnect.toUpperCase(),
}),
});
const status = await getGuacdStatus();
if (status.guacd.status !== "connected") {
throw new Error(t("guacamole.guacdUnavailable"));
}
addLog({
type: "info",
stage: "guac_token",
message: t("guacamole.connecting", {
type: resolvedProtocolForConnect.toUpperCase(),
}),
});
const result = await getGuacamoleTokenFromHost(
hostId,
protocol,
promptedCredentials ?? undefined,
);
if (result) {
setToken(result.token);
setGuacamoleConnectionId(result.guacamoleConnectionId ?? null);
logActivity(resolvedProtocolForConnect, hostId, hostName).catch(() => {});
}
}, [
hostId,
hostName,
protocol,
promptedCredentials,
resolvedProtocolForConnect,
addLog,
t,
]);
const tokenRetry = useConnectionRetry({
connect: async () => {
try {
await fetchToken();
tokenRetryRef.current.markConnected();
} catch (err: unknown) {
const message = getErrorMessage(err, t("guacamole.failedToConnect"));
setError(message || t("guacamole.failedToConnect"));
addLog({ type: "error", stage: "error", message });
tokenRetryRef.current.markFailed();
}
},
enabled: !needsCredentialPrompt || !!promptedCredentials,
autoStart: false,
});
const tokenRetryRef = useRef(tokenRetry);
tokenRetryRef.current = tokenRetry;
useEffect(() => {
if (needsCredentialPrompt && !promptedCredentials) {
setPromptOpen(true);
return;
}
setToken(null);
setGuacamoleConnectionId(null);
setError(null);
(async () => {
if (isElectron()) {
const origin = await resolveConnectionOrigin({
connectionType: resolvedProtocolForConnect,
});
if (origin === "remote") {
const remoteConfig = (await window.electronAPI?.invoke?.(
"get-remote-sync-config",
)) as { serverUrl?: string } | null;
if (!remoteConfig?.serverUrl) {
setError(t("errors.remoteServerRequired"));
return;
}
}
}
try {
const status = await getGuacdStatus();
if (status.guacd.status !== "connected") {
setError(t("guacamole.guacdUnavailable"));
return;
}
const result = await getGuacamoleTokenFromHost(
hostId,
protocol,
promptedCredentials ?? undefined,
);
if (result) {
setToken(result.token);
setGuacamoleConnectionId(result.guacamoleConnectionId ?? null);
logActivity(resolvedProtocolForConnect, hostId, hostName).catch(
() => {},
);
}
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : t("guacamole.failedToConnect");
setError(message || t("guacamole.failedToConnect"));
}
})();
}, [
hostId,
hostName,
protocol,
retryCount,
t,
needsCredentialPrompt,
promptedCredentials,
resolvedProtocolForConnect,
]);
clearLogs();
tokenRetryRef.current.reset();
tokenRetryRef.current.retryNow();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hostId, protocol, needsCredentialPrompt, promptedCredentials]);
const handleReconnect = useCallback(() => {
setConnectionError(null);
setError(null);
setToken(null);
setIsDisplayReady(false);
if (needsCredentialPrompt) {
setPromptedCredentials(null);
setPromptUsername("");
setPromptPassword("");
setPromptDomain(hostConfig.domain ?? "");
setPromptOpen(true);
return;
}
setRetryCount((c) => c + 1);
}, [needsCredentialPrompt]);
clearLogs();
tokenRetryRef.current.reset();
tokenRetryRef.current.retryNow();
}, [needsCredentialPrompt, hostConfig.domain, clearLogs]);
useEffect(() => {
if (!tabId) return;
@@ -278,6 +343,7 @@ const GuacamoleAppInner = React.forwardRef<
setPromptedCredentials({
username: promptUsername,
password: promptPassword,
domain: promptDomain,
});
setPromptOpen(false);
}}
@@ -293,6 +359,16 @@ const GuacamoleAppInner = React.forwardRef<
onChange={(e) => setPromptUsername(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("hosts.guac.domain")}
</label>
<Input
placeholder="WORKGROUP"
value={promptDomain}
onChange={(e) => setPromptDomain(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("hosts.guac.password")}
@@ -315,41 +391,11 @@ const GuacamoleAppInner = React.forwardRef<
);
}
if (error) {
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)" }}
/>
<p
className="text-sm font-semibold"
style={{ color: "var(--foreground)" }}
>
{t("guacamole.connectionFailed")}
</p>
<p
className="text-xs max-w-xs text-center"
style={{ color: "var(--foreground-secondary)" }}
>
{error}
</p>
<Button variant="outline" size="sm" onClick={handleReconnect}>
<RefreshCw className="size-4 mr-2" />
{t("guacamole.retry")}
</Button>
</div>
);
}
if (!token) {
if (error || !token) {
return (
<div className="relative w-full h-full">
<SimpleLoader
visible={true}
<ConnectionScreen
status={error ? tokenRetry.status : "connecting"}
message={t("guacamole.connecting", {
type: (
protocol ||
@@ -357,50 +403,33 @@ const GuacamoleAppInner = React.forwardRef<
"remote"
).toUpperCase(),
})}
attempt={tokenRetry.attempt}
maxAttempts={tokenRetry.maxAttempts}
nextRetryInMs={tokenRetry.nextRetryInMs}
onManualRetry={handleReconnect}
retryLabel={t("guacamole.retry")}
/>
</div>
);
}
const resolvedProtocol = resolvedProtocolForConnect;
const configuredDpi = readConfiguredDimension(
hostConfig.guacamoleConfig?.dpi,
);
const configuredWidth = readConfiguredDimension(
hostConfig.guacamoleConfig?.width,
);
const configuredHeight = readConfiguredDimension(
hostConfig.guacamoleConfig?.height,
);
const configuredDpi = readConfiguredDimension(guacConfig.dpi);
const configuredWidth = readConfiguredDimension(guacConfig.width);
const configuredHeight = readConfiguredDimension(guacConfig.height);
return (
<div className="relative w-full h-full">
{connectionError && (
<div
className="absolute inset-0 flex flex-col items-center justify-center gap-4 z-50"
style={{ backgroundColor: "var(--bg-base)" }}
>
<AlertCircle
className="size-10"
style={{ color: "var(--foreground)" }}
/>
<p
className="text-sm font-semibold"
style={{ color: "var(--foreground)" }}
>
{t("guacamole.connectionFailed")}
</p>
<p
className="text-xs max-w-xs text-center"
style={{ color: "var(--foreground-secondary)" }}
>
{connectionError}
</p>
<Button variant="outline" size="sm" onClick={handleReconnect}>
<RefreshCw className="size-4 mr-2" />
{t("guacamole.reconnect")}
</Button>
</div>
{(!isDisplayReady || connectionError) && (
<ConnectionScreen
status={connectionError ? "disconnected" : "connecting"}
message={t("guacamole.connecting", {
type: resolvedProtocol.toUpperCase(),
})}
onManualRetry={handleReconnect}
retryLabel={t("guacamole.reconnect")}
className="z-50"
/>
)}
<GuacamoleDisplay
key={`${token}-${touchMode}`}
@@ -415,12 +444,41 @@ const GuacamoleAppInner = React.forwardRef<
}}
isVisible={isVisible}
touchMode={touchMode}
onError={(err) => setConnectionError(err)}
allowUpload={allowUpload && filesystem !== null}
onConnect={() => setIsDisplayReady(true)}
onError={(err) => {
setConnectionError(err);
addLog({ type: "error", stage: "error", message: err });
}}
onStageChange={(stage) =>
addLog({
type: "info",
stage,
message: t("guacamole.connecting", {
type: resolvedProtocol.toUpperCase(),
}),
})
}
onFilesystem={setFilesystem}
onDropFiles={handleDropFiles}
/>
{filesystem && fileBrowserOpen && (
<GuacamoleFileBrowser
filesystem={filesystem}
allowUpload={allowUpload}
allowDownload={allowDownload}
pendingUploads={pendingUploads}
onPendingUploadsHandled={() => setPendingUploads([])}
onClose={() => setFileBrowserOpen(false)}
/>
)}
<GuacamoleToolbar
displayRef={displayRef}
protocol={resolvedProtocol}
touchMode={touchMode}
hasFilesystem={filesystem !== null}
fileBrowserOpen={fileBrowserOpen}
onToggleFileBrowser={() => setFileBrowserOpen((open) => !open)}
onTouchModeChange={setTouchMode}
/>
{shareModalOpen && guacamoleConnectionId && (
+99 -44
View File
@@ -1,3 +1,5 @@
import { getErrorMessage } from "../../lib/error-message.js";
import type React from "react";
import {
useEffect,
useRef,
@@ -7,9 +9,9 @@ import {
useCallback,
} from "react";
import Guacamole from "guacamole-common-js";
import { Upload } from "lucide-react";
import { useTranslation } from "react-i18next";
import { getGuacamoleToken, isElectron } from "@/main-axios.ts";
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
import { getBasePath } from "@/lib/base-path.ts";
import { buildGuacamoleWebSocketBaseUrl } from "./guacamole-websocket-url.ts";
import {
@@ -22,6 +24,9 @@ import {
pasteTextToRemote,
} from "./guacamole-clipboard.ts";
import { getGuacamoleDisplaySize } from "./guacamole-display-size.ts";
import { bindPointerInput } from "./guacamole-pointer.ts";
import { guacStateToStage } from "@/components/connection/connection-status.ts";
import type { ConnectionStage } from "@/types/connection-log.ts";
export type GuacamoleConnectionType = "rdp" | "vnc" | "telnet";
@@ -46,6 +51,7 @@ export interface GuacamoleDisplayHandle {
sendKey: (keysym: number, pressed: boolean) => void;
sendMouse: (x: number, y: number, buttonMask: number) => void;
setClipboard: (data: string) => void;
getFilesystem: () => Guacamole.Object | null;
}
export type GuacamoleTouchMode = "touchscreen" | "touchpad";
@@ -54,9 +60,13 @@ interface GuacamoleDisplayProps {
connectionConfig: GuacamoleConnectionConfig;
isVisible: boolean;
touchMode?: GuacamoleTouchMode | null;
allowUpload?: boolean;
onConnect?: () => void;
onDisconnect?: () => void;
onError?: (error: string) => void;
onFilesystem?: (filesystem: Guacamole.Object | null) => void;
onDropFiles?: (files: File[]) => void;
onStageChange?: (stage: ConnectionStage) => void;
}
const isDev = import.meta.env.DEV;
@@ -65,7 +75,18 @@ export const GuacamoleDisplay = forwardRef<
GuacamoleDisplayHandle,
GuacamoleDisplayProps
>(function GuacamoleDisplay(
{ connectionConfig, isVisible, touchMode, onConnect, onDisconnect, onError },
{
connectionConfig,
isVisible,
touchMode,
allowUpload = false,
onConnect,
onDisconnect,
onError,
onFilesystem,
onDropFiles,
onStageChange,
},
ref,
) {
const { t } = useTranslation();
@@ -77,6 +98,13 @@ export const GuacamoleDisplay = forwardRef<
const displayRef = useRef<HTMLDivElement>(null);
const displayElementRef = useRef<HTMLElement | null>(null);
const clientRef = useRef<Guacamole.Client | null>(null);
const filesystemRef = useRef<Guacamole.Object | null>(null);
// Held in a ref so tearing down the client can report the loss without
// rebuilding the connect callback whenever the parent re-renders.
const onFilesystemRef = useRef(onFilesystem);
onFilesystemRef.current = onFilesystem;
const onStageChangeRef = useRef(onStageChange);
onStageChangeRef.current = onStageChange;
const keyboardRef = useRef<Guacamole.Keyboard | null>(null);
const scaleRef = useRef<number>(1);
const resizeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@@ -94,6 +122,10 @@ export const GuacamoleDisplay = forwardRef<
const client = clientRef.current;
clientRef.current = null;
isConnectingRef.current = false;
if (filesystemRef.current) {
filesystemRef.current = null;
onFilesystemRef.current?.(null);
}
if (!client) return;
try {
@@ -132,6 +164,7 @@ export const GuacamoleDisplay = forwardRef<
writer.sendEnd();
}
},
getFilesystem: () => filesystemRef.current,
}));
const getWebSocketConnection = useCallback(
@@ -211,8 +244,7 @@ export const GuacamoleDisplay = forwardRef<
if (displaySize.dpi) params.set("dpi", String(displaySize.dpi));
return { url: wsBase, query: params.toString() };
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
const errorMessage = getErrorMessage(error);
onError?.(errorMessage);
return null;
}
@@ -421,10 +453,9 @@ export const GuacamoleDisplay = forwardRef<
setIsReady(true);
}
const sendMouseEvent = (event: Guacamole.Mouse.MouseEvent) => {
const sendMouseState = (state: Guacamole.Mouse.State) => {
displayElement.focus({ preventScroll: true });
const scale = scaleRef.current;
const state = event.state;
const adjustedState = new Guacamole.Mouse.State(
Math.round(state.x / scale),
Math.round(state.y / scale),
@@ -437,30 +468,7 @@ export const GuacamoleDisplay = forwardRef<
client.sendMouseState(adjustedState);
};
if (touchMode === "touchscreen") {
const touchscreen = new Guacamole.Mouse.Touchscreen(displayElement);
touchscreen.onEach(["mousedown", "mousemove", "mouseup"], sendMouseEvent);
} else if (touchMode === "touchpad") {
const touchpad = new Guacamole.Mouse.Touchpad(displayElement);
touchpad.onEach(["mousedown", "mousemove", "mouseup"], sendMouseEvent);
} else {
const mouse = new Guacamole.Mouse(displayElement);
const sendMouseState = (state: Guacamole.Mouse.State) => {
displayElement.focus({ preventScroll: true });
const scale = scaleRef.current;
const adjustedState = new Guacamole.Mouse.State(
Math.round(state.x / scale),
Math.round(state.y / scale),
state.left,
state.middle,
state.right,
state.up,
state.down,
) as Guacamole.Mouse.State;
client.sendMouseState(adjustedState);
};
mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = sendMouseState;
}
bindPointerInput(displayElement, touchMode, sendMouseState);
const keyboard = new Guacamole.Keyboard(displayElement);
keyboardRef.current = keyboard;
@@ -485,6 +493,7 @@ export const GuacamoleDisplay = forwardRef<
client.onstatechange = (state: number) => {
if (!isMountedRef.current || clientRef.current !== client) return;
onStageChangeRef.current?.(guacStateToStage(state));
switch (state) {
case 0:
break;
@@ -555,6 +564,20 @@ export const GuacamoleDisplay = forwardRef<
Guacamole.AudioPlayer.getInstance(stream, mimetype);
};
// Only fires when the connection enables drive redirection; guacd exposes
// the redirected drive as a single filesystem object.
client.onfilesystem = (filesystem: Guacamole.Object) => {
if (!isMountedRef.current || clientRef.current !== client) return;
filesystemRef.current = filesystem;
onFilesystemRef.current?.(filesystem);
filesystem.onundefine = () => {
if (filesystemRef.current !== filesystem) return;
filesystemRef.current = null;
onFilesystemRef.current?.(null);
};
};
client.onfile = (
stream: Guacamole.InputStream,
mimetype: string,
@@ -593,9 +616,7 @@ export const GuacamoleDisplay = forwardRef<
if (!isMountedRef.current) return;
setIsReady(false);
setHasError(true);
onError?.(
error instanceof Error ? error.message : t("guacamole.connectionError"),
);
onError?.(getErrorMessage(error, t("guacamole.connectionError")));
}
}, [
getWebSocketConnection,
@@ -747,19 +768,49 @@ export const GuacamoleDisplay = forwardRef<
};
}, [isReady, syncClipboard]);
const connectingMessage = t("guacamole.connecting", {
type: (
connectionConfig.protocol ||
connectionConfig.type ||
"remote"
).toUpperCase(),
});
const canDropFiles = allowUpload && onDropFiles != null;
const [isDraggingFiles, setIsDraggingFiles] = useState(false);
// Nested elements fire dragleave as the pointer crosses them, so track depth
// rather than clearing the highlight on the first leave.
const dragDepthRef = useRef(0);
const handleDragEnter = useCallback(
(event: React.DragEvent) => {
if (!canDropFiles || !event.dataTransfer.types.includes("Files")) return;
event.preventDefault();
dragDepthRef.current += 1;
setIsDraggingFiles(true);
},
[canDropFiles],
);
const handleDragLeave = useCallback(() => {
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) setIsDraggingFiles(false);
}, []);
const handleDrop = useCallback(
(event: React.DragEvent) => {
if (!canDropFiles) return;
event.preventDefault();
dragDepthRef.current = 0;
setIsDraggingFiles(false);
const files = Array.from(event.dataTransfer.files);
if (files.length > 0) onDropFiles?.(files);
},
[canDropFiles, onDropFiles],
);
return (
<div
ref={containerRef}
className="absolute inset-0 overflow-hidden"
style={{ backgroundColor: "var(--bg-base)" }}
onDragEnter={handleDragEnter}
onDragOver={canDropFiles ? (e) => e.preventDefault() : undefined}
onDragLeave={canDropFiles ? handleDragLeave : undefined}
onDrop={handleDrop}
>
<div
ref={displayRef}
@@ -770,10 +821,14 @@ export const GuacamoleDisplay = forwardRef<
}}
/>
<SimpleLoader
visible={!isReady && !hasError}
message={connectingMessage}
/>
{isDraggingFiles && (
<div className="absolute inset-0 z-30 flex items-center justify-center pointer-events-none bg-background/70 backdrop-blur-sm">
<div className="flex items-center gap-2 rounded-sm border-2 border-dashed border-border px-4 py-3 text-sm font-semibold">
<Upload className="size-4" />
{t("guacamole.files.dropToUpload")}
</div>
</div>
)}
</div>
);
});
@@ -0,0 +1,244 @@
import { getErrorMessage } from "../../lib/error-message.js";
import { useCallback, useEffect, useRef, useState } from "react";
import Guacamole from "guacamole-common-js";
import { useTranslation } from "react-i18next";
import {
ArrowUp,
Download,
File as FileIcon,
Folder,
RefreshCw,
Upload,
X,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/button.tsx";
import {
downloadFile,
listDirectory,
parentPath,
saveBlobAs,
uploadFile,
type RemoteFileEntry,
} from "./guacamole-filesystem.ts";
interface GuacamoleFileBrowserProps {
filesystem: Guacamole.Object;
allowUpload: boolean;
allowDownload: boolean;
pendingUploads: File[];
onPendingUploadsHandled: () => void;
onClose: () => void;
}
export function GuacamoleFileBrowser({
filesystem,
allowUpload,
allowDownload,
pendingUploads,
onPendingUploadsHandled,
onClose,
}: GuacamoleFileBrowserProps) {
const { t } = useTranslation();
const [path, setPath] = useState("/");
const [entries, setEntries] = useState<RemoteFileEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [busyName, setBusyName] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const refresh = useCallback(
async (target: string) => {
setLoading(true);
setError(null);
try {
setEntries(await listDirectory(filesystem, target));
} catch (err) {
setEntries([]);
setError(getErrorMessage(err, t("guacamole.files.listFailed")));
} finally {
setLoading(false);
}
},
[filesystem, t],
);
useEffect(() => {
void refresh(path);
}, [refresh, path]);
const upload = useCallback(
async (files: File[]) => {
for (const file of files) {
setBusyName(file.name);
try {
await uploadFile(filesystem, path, file);
toast.success(t("guacamole.files.uploaded", { name: file.name }));
} catch (err) {
toast.error(
err instanceof Error
? err.message
: t("guacamole.files.uploadFailed", { name: file.name }),
);
} finally {
setBusyName(null);
}
}
void refresh(path);
},
[filesystem, path, refresh, t],
);
// Files dropped on the display land here so they share the browser's current
// directory rather than always going to the drive root.
useEffect(() => {
if (pendingUploads.length === 0) return;
onPendingUploadsHandled();
if (!allowUpload) {
toast.error(t("guacamole.files.uploadDisabled"));
return;
}
void upload(pendingUploads);
}, [pendingUploads, onPendingUploadsHandled, allowUpload, upload, t]);
const download = useCallback(
async (entry: RemoteFileEntry) => {
setBusyName(entry.name);
try {
const { blob, filename } = await downloadFile(filesystem, entry.path);
saveBlobAs(blob, filename);
} catch (err) {
toast.error(
err instanceof Error
? err.message
: t("guacamole.files.downloadFailed", { name: entry.name }),
);
} finally {
setBusyName(null);
}
},
[filesystem, t],
);
return (
<div className="absolute right-2 top-2 bottom-2 z-30 flex w-80 flex-col rounded-sm border border-border bg-background/95 shadow-lg backdrop-blur-sm">
<div className="flex items-center gap-1 border-b border-border px-2 py-1.5">
<span className="flex-1 truncate text-xs font-semibold">
{t("guacamole.files.title")}
</span>
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={onClose}
aria-label={t("common.close")}
>
<X className="size-3.5" />
</Button>
</div>
<div className="flex items-center gap-1 border-b border-border px-2 py-1.5">
<Button
variant="ghost"
size="icon"
className="size-6"
disabled={path === "/"}
onClick={() => setPath(parentPath(path))}
aria-label={t("guacamole.files.parent")}
>
<ArrowUp className="size-3.5" />
</Button>
<span className="flex-1 truncate text-[11px] text-muted-foreground">
{path}
</span>
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={() => void refresh(path)}
aria-label={t("guacamole.files.refresh")}
>
<RefreshCw className="size-3.5" />
</Button>
</div>
<div className="flex-1 overflow-y-auto">
{loading && (
<p className="px-2 py-3 text-[11px] text-muted-foreground">
{t("common.loading")}
</p>
)}
{!loading && error && (
<p className="px-2 py-3 text-[11px] text-red-500">{error}</p>
)}
{!loading && !error && entries.length === 0 && (
<p className="px-2 py-3 text-[11px] text-muted-foreground">
{t("guacamole.files.empty")}
</p>
)}
{!loading &&
!error &&
entries.map((entry) => (
<div
key={entry.path}
className="flex items-center gap-1.5 px-2 py-1 hover:bg-muted"
>
{entry.isDirectory ? (
<Folder className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<FileIcon className="size-3.5 shrink-0 text-muted-foreground" />
)}
<button
type="button"
className="flex-1 truncate text-left text-[11px] disabled:opacity-60"
disabled={!entry.isDirectory}
onClick={() => entry.isDirectory && setPath(entry.path)}
>
{entry.name}
</button>
{!entry.isDirectory && allowDownload && (
<Button
variant="ghost"
size="icon"
className="size-5 shrink-0"
disabled={busyName === entry.name}
onClick={() => void download(entry)}
aria-label={t("guacamole.files.download")}
>
<Download className="size-3" />
</Button>
)}
</div>
))}
</div>
{allowUpload && (
<div className="border-t border-border px-2 py-1.5">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => {
const files = Array.from(e.target.files ?? []);
e.target.value = "";
if (files.length > 0) void upload(files);
}}
/>
<Button
variant="outline"
size="sm"
className="h-7 w-full text-[11px]"
disabled={busyName !== null}
onClick={() => fileInputRef.current?.click()}
>
<Upload className="mr-1 size-3" />
{busyName !== null
? t("guacamole.files.uploading", { name: busyName })
: t("guacamole.files.upload")}
</Button>
</div>
)}
</div>
);
}
+24 -3
View File
@@ -13,6 +13,7 @@ import {
ChevronUp,
ChevronDown,
ChevronsLeftRight,
FolderOpen,
Touchpad,
MousePointer,
} from "lucide-react";
@@ -33,6 +34,9 @@ interface GuacamoleToolbarProps {
displayRef: React.RefObject<GuacamoleDisplayHandle>;
protocol: "rdp" | "vnc" | "telnet";
touchMode?: GuacamoleTouchMode | null;
hasFilesystem?: boolean;
fileBrowserOpen?: boolean;
onToggleFileBrowser?: () => void;
onTouchModeChange?: (mode: GuacamoleTouchMode) => void;
}
@@ -40,7 +44,7 @@ const MODIFIER_KEYSYMS = {
ctrl: 0xffe3,
alt: 0xffe9,
shift: 0xffe1,
win: 0xff67,
win: 0xffeb,
} as const;
const FKEY_KEYSYMS = Array.from({ length: 12 }, (_, i) => 0xffbe + i);
@@ -115,6 +119,9 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
displayRef,
protocol,
touchMode,
hasFilesystem = false,
fileBrowserOpen = false,
onToggleFileBrowser,
onTouchModeChange,
}) => {
const { t } = useTranslation();
@@ -329,6 +336,20 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
</>
)}
{/* Drive files — only once guacd reports a redirected filesystem */}
{hasFilesystem && onToggleFileBrowser && (
<>
<div className={SEP} />
<TipIconBtn
tooltip={t("guacamole.files.title")}
onClick={onToggleFileBrowser}
className={cn(fileBrowserOpen && "bg-muted text-foreground")}
>
<FolderOpen className="size-3.5" />
</TipIconBtn>
</>
)}
{/* System combos — RDP/VNC only */}
{isRdpVnc && (
<>
@@ -341,13 +362,13 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
</TipBtn>
<TipBtn
tooltip={t("guacamole.toolbar.winL")}
onClick={() => sendCombo(0xff67, 0x006c)}
onClick={() => sendCombo(MODIFIER_KEYSYMS.win, 0x006c)}
>
Win+L
</TipBtn>
<TipBtn
tooltip={t("guacamole.toolbar.winKey")}
onClick={() => sendCombo(0xff67)}
onClick={() => sendCombo(MODIFIER_KEYSYMS.win)}
>
Win
</TipBtn>
@@ -0,0 +1,165 @@
import Guacamole from "guacamole-common-js";
// The root stream of a Guacamole.Object maps stream name to mimetype; a stream
// carrying that same mimetype is itself a directory.
export const STREAM_INDEX_MIMETYPE =
"application/vnd.glyptodon.guacamole.stream-index+json";
export interface RemoteFileEntry {
name: string;
path: string;
mimetype: string;
isDirectory: boolean;
}
export function isDirectoryMimetype(mimetype: string): boolean {
return mimetype === STREAM_INDEX_MIMETYPE;
}
export function joinPath(parent: string, name: string): string {
return parent === "/" ? `/${name}` : `${parent}/${name}`;
}
export function parentPath(path: string): string {
const cut = path.lastIndexOf("/");
return cut <= 0 ? "/" : path.slice(0, cut);
}
export function basename(path: string): string {
return path.slice(path.lastIndexOf("/") + 1) || path;
}
// guacd returns absolute paths as keys, but a malformed or relative key would
// otherwise produce a broken path on the next descent.
export function parseDirectoryIndex(
json: string,
path: string,
): RemoteFileEntry[] {
const index = JSON.parse(json) as Record<string, string>;
return Object.entries(index)
.map(([key, mimetype]) => {
const name = basename(key);
return {
name,
path: key.startsWith("/") ? key : joinPath(path, name),
mimetype,
isDirectory: isDirectoryMimetype(mimetype),
};
})
.sort((a, b) =>
a.isDirectory === b.isDirectory
? a.name.localeCompare(b.name)
: a.isDirectory
? -1
: 1,
);
}
// A refused stream is answered with an error ack that never reaches the body
// callback, so every request needs its own deadline to avoid hanging forever.
const REQUEST_TIMEOUT_MS = 15000;
function requestStream(
filesystem: Guacamole.Object,
path: string,
): Promise<{ stream: Guacamole.InputStream; mimetype: string }> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error(`Timed out reading ${path}`)),
REQUEST_TIMEOUT_MS,
);
filesystem.requestInputStream(path, (stream, mimetype) => {
clearTimeout(timeout);
resolve({ stream, mimetype });
});
});
}
export async function listDirectory(
filesystem: Guacamole.Object,
path: string,
): Promise<RemoteFileEntry[]> {
const { stream, mimetype } = await requestStream(filesystem, path);
if (!isDirectoryMimetype(mimetype)) {
throw new Error(`${path} is not a directory`);
}
const json = await new Promise<string>((resolve) => {
const reader = new Guacamole.StringReader(stream);
let text = "";
reader.ontext = (chunk: string) => {
text += chunk;
};
reader.onend = () => resolve(text);
});
return parseDirectoryIndex(json, path);
}
export async function downloadFile(
filesystem: Guacamole.Object,
path: string,
): Promise<{ blob: Blob; filename: string }> {
const { stream, mimetype } = await requestStream(filesystem, path);
if (isDirectoryMimetype(mimetype)) {
throw new Error(`${path} is a directory`);
}
const blob = await new Promise<Blob>((resolve) => {
const reader = new Guacamole.BlobReader(stream, mimetype);
reader.onend = () => resolve(reader.getBlob());
});
return { blob, filename: basename(path) };
}
export function uploadFile(
filesystem: Guacamole.Object,
directory: string,
file: File,
onProgress?: (sent: number, total: number) => void,
): Promise<void> {
return new Promise((resolve, reject) => {
const path = joinPath(directory, file.name);
const stream = filesystem.createOutputStream(
file.type || "application/octet-stream",
path,
);
const writer = new Guacamole.BlobWriter(stream);
// A rejected blob stops the writer without firing onerror or oncomplete —
// only the error ack reports it, so without this the upload hangs forever.
writer.onack = (status: Guacamole.Status) => {
if (status.isError()) {
reject(
new Error(
status.message || `Server refused the upload of ${file.name}`,
),
);
}
};
writer.onerror = () => reject(new Error(`Failed to upload ${file.name}`));
writer.onprogress = (_blob: Blob, offset: number) =>
onProgress?.(offset, file.size);
writer.oncomplete = () => {
writer.sendEnd();
onProgress?.(file.size, file.size);
resolve();
};
writer.sendBlob(file);
});
}
export function saveBlobAs(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}
@@ -0,0 +1,37 @@
import Guacamole from "guacamole-common-js";
export type GuacamoleTouchMode = "touchscreen" | "touchpad";
/**
* Guacamole.Mouse listens for mousedown/mousemove/mouseup; the Touchscreen and
* Touchpad emulators listen only for touch events. The two sets do not overlap,
* so binding one instead of the other leaves that input dead.
*
* A touch-capable device is not a device without a pointer: laptops with a
* touchscreen report maxTouchPoints > 0 and are still driven by a mouse. The
* physical pointer is therefore always bound, and a touch emulator is layered
* on top when one is selected.
*/
export function bindPointerInput(
element: HTMLElement,
touchMode: GuacamoleTouchMode | null | undefined,
onState: (state: Guacamole.Mouse.State) => void,
): void {
const mouse = new Guacamole.Mouse(element);
mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = onState;
if (touchMode === "touchscreen") {
const touchscreen = new Guacamole.Mouse.Touchscreen(element);
touchscreen.onEach(["mousedown", "mousemove", "mouseup"], (event) =>
onState(event.state),
);
return;
}
if (touchMode === "touchpad") {
const touchpad = new Guacamole.Mouse.Touchpad(element);
touchpad.onEach(["mousedown", "mousemove", "mouseup"], (event) =>
onState(event.state),
);
}
}
@@ -1,6 +1,9 @@
import { useCallback, useRef } from "react";
import { GRID_SIZE } from "@/types/homepage-types";
import type { CanvasWidget, ResizeState } from "@/types/homepage-types";
import {
GRID_SIZE,
type CanvasWidget,
type ResizeState,
} from "@/types/homepage-types";
import { snapToGrid } from "./snapToGrid";
import { getWidgetType } from "../widgets/WidgetRegistry";
@@ -1,46 +0,0 @@
import { useTranslation } from "react-i18next";
import { Input } from "@/components/input";
import type {
DockerActivityConfig,
WidgetEditFormProps,
} from "@/types/homepage-types";
export function DockerActivityEditForm({
config,
onChange,
}: WidgetEditFormProps<DockerActivityConfig>) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.maxItems")}
</label>
<Input
type="number"
min={1}
max={50}
value={config.maxItems}
onChange={(e) =>
onChange({
...config,
maxItems: Math.max(1, Number(e.target.value)),
})
}
className="h-8 text-xs"
/>
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={config.showHostName}
onChange={(e) =>
onChange({ ...config, showHostName: e.target.checked })
}
className="accent-accent-brand"
/>
{t("homepage.showHostName")}
</label>
</div>
);
}
@@ -1,105 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type {
SshQuickConnectConfig,
WidgetEditFormProps,
} from "@/types/homepage-types";
import { getSSHHosts } from "@/api/ssh-host-management-api";
export function SshQuickConnectEditForm({
config,
onChange,
}: WidgetEditFormProps<SshQuickConnectConfig>) {
const { t } = useTranslation();
const [hosts, setHosts] = useState<{ id: number; name: string }[]>([]);
useEffect(() => {
getSSHHosts()
.then((h) => setHosts(h.map((x) => ({ id: x.id, name: x.name }))))
.catch(() => {});
}, []);
const toggleHost = (id: number) => {
const next = config.hostIds.includes(id)
? config.hostIds.filter((x) => x !== id)
: [...config.hostIds, id];
onChange({ ...config, hostIds: next });
};
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.host")}
</label>
<div className="flex flex-col gap-0.5 max-h-40 overflow-y-auto border border-border/40 p-1">
{hosts.map((h) => (
<label
key={h.id}
className="flex items-center gap-2 text-[10px] cursor-pointer px-1 py-0.5 hover:bg-muted/40"
>
<input
type="checkbox"
checked={config.hostIds.includes(h.id)}
onChange={() => toggleHost(h.id)}
className="accent-accent-brand"
/>
{h.name}
</label>
))}
</div>
<span className="text-[9px] text-muted-foreground">
{t("homepage.hostGridAllHint")}
</span>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.connectionType")}
</label>
<div className="flex gap-1">
{(["terminal", "files", "docker"] as const).map((ct) => (
<button
key={ct}
type="button"
onClick={() => onChange({ ...config, connectionType: ct })}
className={`px-2 py-0.5 text-[10px] border transition-colors capitalize ${config.connectionType === ct ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground"}`}
>
{ct}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.layout")}
</label>
<div className="flex gap-1">
{(["list", "grid"] as const).map((l) => (
<button
key={l}
type="button"
onClick={() => onChange({ ...config, layout: l })}
className={`px-2 py-0.5 text-[10px] border transition-colors capitalize ${config.layout === l ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground"}`}
>
{l}
</button>
))}
</div>
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={config.showStatus}
onChange={(e) =>
onChange({ ...config, showStatus: e.target.checked })
}
className="accent-accent-brand"
/>
{t("homepage.showStatus")}
</label>
</div>
);
}
@@ -110,224 +110,224 @@ export function WidgetEditDialog({
case "service_link":
return (
<ServiceLinkEditForm
config={config as ServiceLinkConfig}
config={config as unknown as ServiceLinkConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "folder":
return (
<FolderEditForm
config={config as FolderConfig}
config={config as unknown as FolderConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "notes":
return (
<NotesEditForm
config={config as NotesConfig}
config={config as unknown as NotesConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "bookmark_list":
return (
<BookmarkListEditForm
config={config as BookmarkListConfig}
config={config as unknown as BookmarkListConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "clock":
return (
<ClockEditForm
config={config as ClockConfig}
config={config as unknown as ClockConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "host_status":
return (
<HostStatusEditForm
config={config as HostStatusConfig}
config={config as unknown as HostStatusConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "weather":
return (
<WeatherEditForm
config={config as WeatherConfig}
config={config as unknown as WeatherConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "iframe_embed":
return (
<IframeEditForm
config={config as IframeConfig}
config={config as unknown as IframeConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "rss_feed":
return (
<RssFeedEditForm
config={config as RssFeedConfig}
config={config as unknown as RssFeedConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "metrics_chart":
return (
<MetricsChartEditForm
config={config as MetricsChartConfig}
config={config as unknown as MetricsChartConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "host_grid":
return (
<HostGridEditForm
config={config as HostGridConfig}
config={config as unknown as HostGridConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "alert_feed":
return (
<AlertFeedEditForm
config={config as AlertFeedConfig}
config={config as unknown as AlertFeedConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "ping_status":
return (
<PingStatusEditForm
config={config as PingStatusConfig}
config={config as unknown as PingStatusConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "recent_activity":
return (
<RecentActivityEditForm
config={config as RecentActivityConfig}
config={config as unknown as RecentActivityConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "termix_uptime":
return (
<TermixUptimeEditForm
config={config as TermixUptimeConfig}
config={config as unknown as TermixUptimeConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "system_overview":
return (
<SystemOverviewEditForm
config={config as SystemOverviewConfig}
config={config as unknown as SystemOverviewConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "ssh_terminal":
return (
<SshTerminalEditForm
config={config as SshTerminalConfig}
config={config as unknown as SshTerminalConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "quick_connect":
return (
<QuickConnectEditForm
config={config as QuickConnectConfig}
config={config as unknown as QuickConnectConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "file_manager_widget":
return (
<FileManagerWidgetEditForm
config={config as FileManagerWidgetConfig}
config={config as unknown as FileManagerWidgetConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "docker_widget":
return (
<DockerWidgetEditForm
config={config as DockerWidgetConfig}
config={config as unknown as DockerWidgetConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "tunnel_widget":
return (
<TunnelWidgetEditForm
config={config as TunnelWidgetConfig}
config={config as unknown as TunnelWidgetConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "calendar":
return (
<CalendarEditForm
config={config as CalendarConfig}
config={config as unknown as CalendarConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "countdown":
return (
<CountdownEditForm
config={config as CountdownConfig}
config={config as unknown as CountdownConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "search_bar":
return (
<SearchBarEditForm
config={config as SearchBarConfig}
config={config as unknown as SearchBarConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "text_banner":
return (
<TextBannerEditForm
config={config as TextBannerConfig}
config={config as unknown as TextBannerConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "image_widget":
return (
<ImageWidgetEditForm
config={config as ImageWidgetConfig}
config={config as unknown as ImageWidgetConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "markdown_notes":
return (
<MarkdownNotesEditForm
config={config as MarkdownNotesConfig}
config={config as unknown as MarkdownNotesConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "custom_api":
return (
<CustomApiEditForm
config={config as CustomApiConfig}
config={config as unknown as CustomApiConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "service_grid":
return (
<ServiceGridEditForm
config={config as ServiceGridConfig}
config={config as unknown as ServiceGridConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "dashboard_links":
return (
<DashboardLinksEditForm
config={config as DashboardLinksConfig}
config={config as unknown as DashboardLinksConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "search_links":
return (
<SearchLinksEditForm
config={config as SearchLinksConfig}
config={config as unknown as SearchLinksConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "link_tree":
return (
<LinkTreeEditForm
config={config as LinkTreeConfig}
config={config as unknown as LinkTreeConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
@@ -14,8 +14,11 @@ import type {
WidgetComponentProps,
} from "@/types/homepage-types";
import { GRID_SIZE } from "@/types/homepage-types";
import { getAlertFirings, acknowledgeAlertFiring } from "@/api/alerts-api";
import type { AlertFiring } from "@/api/alerts-api";
import {
acknowledgeAlertFiring,
getAlertFirings,
type AlertFiring,
} from "@/api/alerts-api";
import { WidgetTitle } from "./WidgetTitle";
function relativeTime(ts: string): string {
@@ -1,8 +1,11 @@
import { useState } from "react";
import { Clock } from "lucide-react";
import { registerWidget } from "./WidgetRegistry";
import type { ClockConfig, WidgetComponentProps } from "@/types/homepage-types";
import { GRID_SIZE } from "@/types/homepage-types";
import {
GRID_SIZE,
type ClockConfig,
type WidgetComponentProps,
} from "@/types/homepage-types";
import { WidgetTitle } from "./WidgetTitle";
import { usePageVisibleInterval } from "@/hooks/use-page-visible-interval";
@@ -7,8 +7,7 @@ import type {
WidgetComponentProps,
} from "@/types/homepage-types";
import { GRID_SIZE } from "@/types/homepage-types";
import { getServiceLinks } from "@/api/dashboard-api";
import type { ServiceLink } from "@/api/dashboard-api";
import { getServiceLinks, type ServiceLink } from "@/api/dashboard-api";
import { WidgetTitle } from "./WidgetTitle";
function getFaviconUrl(url: string): string {
@@ -1,110 +0,0 @@
import { useEffect, useState } from "react";
import { Container } from "lucide-react";
import { useTranslation } from "react-i18next";
import { registerWidget } from "./WidgetRegistry";
import type {
DockerActivityConfig,
WidgetComponentProps,
} from "@/types/homepage-types";
import { GRID_SIZE } from "@/types/homepage-types";
import { getRecentActivity } from "@/api/dashboard-api";
import type { RecentActivityItem } from "@/api/dashboard-api";
import { WidgetTitle } from "./WidgetTitle";
import { runVisibleInterval } from "../use-visible-interval";
function relativeTime(ts: string): string {
const diff = Date.now() - new Date(ts).getTime();
const m = Math.floor(diff / 60_000);
if (m < 1) return "just now";
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
function DockerActivityWidget({
widget,
config,
}: WidgetComponentProps<DockerActivityConfig>) {
const { t } = useTranslation();
const { maxItems, showHostName } = config;
const [items, setItems] = useState<RecentActivityItem[]>([]);
const [loading, setLoading] = useState(true);
const fetchData = async () => {
try {
const data = await getRecentActivity(maxItems * 5);
setItems(data.filter((i) => i.type === "docker").slice(0, maxItems));
} catch {
/* ignore */
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
return runVisibleInterval(() => {
void fetchData();
}, 30_000);
}, [maxItems]);
if (loading) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
{t("homepage.loading")}
</div>
);
}
if (items.length === 0) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
{t("homepage.noDockerActivity")}
</div>
);
}
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<WidgetTitle
title={widget.title || t("homepage.widgetDockerActivityName")}
icon={<Container size={11} />}
/>
<div className="flex-1 overflow-auto">
{items.map((item) => (
<div
key={item.id}
className="flex items-center gap-2 px-2 py-1.5 border-b border-border/30"
>
<Container size={10} className="shrink-0 text-muted-foreground" />
<div className="flex flex-col min-w-0 flex-1">
{showHostName && (
<span className="text-[10px] font-medium text-foreground truncate">
{item.hostName}
</span>
)}
<span className="text-[9px] text-muted-foreground">
{relativeTime(item.timestamp)}
</span>
</div>
</div>
))}
</div>
</div>
);
}
registerWidget<DockerActivityConfig>({
id: "docker_activity",
name: "Docker Activity",
description: "Recent Docker connection activity across your hosts",
category: "system",
icon: <Container size={14} />,
defaultConfig: { maxItems: 10, showHostName: true },
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 4 },
component: DockerActivityWidget,
});
export { DockerActivityWidget };
@@ -24,9 +24,11 @@ function StatusDot({ status }: { status: string }) {
const color =
status === "online"
? getAccentColor()
: status === "offline"
? "#ef4444"
: "#6b7280";
: status === "reachable"
? "#fbbf24"
: status === "offline"
? "#ef4444"
: "#6b7280";
return (
<span
className="w-1.5 h-1.5 rounded-full shrink-0 inline-block"
@@ -93,6 +93,7 @@ function HostStatusWidget({
const { hostId } = config;
const [hostName, setHostName] = useState<string | null>(null);
const [online, setOnline] = useState<boolean | null>(null);
const [reachable, setReachable] = useState(false);
const [metrics, setMetrics] = useState<ServerMetrics | null>(null);
const needsMetrics = shownMetrics.length > 0;
@@ -114,9 +115,15 @@ function HostStatusWidget({
const poll = async () => {
try {
const s = await getServerStatusById(hostId);
if (!cancelled) setOnline(s.status === "online");
if (!cancelled) {
setOnline(s.status === "online");
setReachable(s.status === "reachable");
}
} catch {
if (!cancelled) setOnline(false);
if (!cancelled) {
setOnline(false);
setReachable(false);
}
}
if (needsMetrics) {
@@ -173,13 +180,21 @@ function HostStatusWidget({
}
const onlineColor =
online === null ? "#6b7280" : online ? getAccentColor() : "#ef4444";
online === null
? "#6b7280"
: online
? getAccentColor()
: reachable
? "#fbbf24"
: "#ef4444";
const onlineLabel =
online === null
? t("common.unknown")
: online
? t("common.online")
: t("common.offline");
: reachable
? t("common.reachable", { defaultValue: "Reachable" })
: t("common.offline");
const networkIfaces = metrics?.network?.interfaces ?? [];
const totalRx = networkIfaces.reduce((sum, iface) => {
@@ -8,9 +8,11 @@ import type {
WidgetComponentProps,
} from "@/types/homepage-types";
import { GRID_SIZE } from "@/types/homepage-types";
import { getMetricsHistory } from "@/api/host-metrics-api";
import {
getMetricsHistory,
type MetricsHistoryRow,
} from "@/api/host-metrics-api";
import { getSSHHosts } from "@/api/ssh-host-management-api";
import type { MetricsHistoryRow } from "@/api/host-metrics-api";
import { WidgetTitle } from "./WidgetTitle";
import { runVisibleInterval } from "../use-visible-interval";
@@ -1,7 +1,10 @@
import { StickyNote } from "lucide-react";
import { registerWidget } from "./WidgetRegistry";
import type { NotesConfig, WidgetComponentProps } from "@/types/homepage-types";
import { GRID_SIZE } from "@/types/homepage-types";
import {
GRID_SIZE,
type NotesConfig,
type WidgetComponentProps,
} from "@/types/homepage-types";
import { WidgetTitle } from "./WidgetTitle";
function NotesWidget({ widget, config }: WidgetComponentProps<NotesConfig>) {
@@ -15,8 +15,10 @@ import type {
WidgetComponentProps,
} from "@/types/homepage-types";
import { GRID_SIZE } from "@/types/homepage-types";
import { getRecentActivity } from "@/api/dashboard-api";
import type { RecentActivityItem } from "@/api/dashboard-api";
import {
getRecentActivity,
type RecentActivityItem,
} from "@/api/dashboard-api";
import { WidgetTitle } from "./WidgetTitle";
import { runVisibleInterval } from "../use-visible-interval";
@@ -1,147 +0,0 @@
import { useCallback, useState } from "react";
import { Zap, Terminal, FolderOpen, Container } from "lucide-react";
import { useTranslation } from "react-i18next";
import { registerWidget } from "./WidgetRegistry";
import type {
SshQuickConnectConfig,
WidgetComponentProps,
} from "@/types/homepage-types";
import { GRID_SIZE } from "@/types/homepage-types";
import { getSSHHosts } from "@/api/ssh-host-management-api";
import type { SSHHostWithStatus } from "@/main-axios";
import { WidgetTitle } from "./WidgetTitle";
import { usePageVisibleInterval } from "@/hooks/use-page-visible-interval";
function getAccentColor(): string {
return (
getComputedStyle(document.documentElement)
.getPropertyValue("--accent-brand")
.trim() || "#f59145"
);
}
function TypeIcon({ type }: { type: string }) {
if (type === "files") return <FolderOpen size={10} />;
if (type === "docker") return <Container size={10} />;
return <Terminal size={10} />;
}
function openTab(host: SSHHostWithStatus, connectionType: string) {
window.dispatchEvent(
new CustomEvent("termix:open-tab", {
detail: {
type:
connectionType === "files"
? "files"
: connectionType === "docker"
? "docker"
: "terminal",
host,
},
}),
);
}
function SshQuickConnectWidget({
widget,
config,
}: WidgetComponentProps<SshQuickConnectConfig>) {
const { t } = useTranslation();
const { hostIds, connectionType, showStatus, layout } = config;
const [hosts, setHosts] = useState<SSHHostWithStatus[]>([]);
const [loading, setLoading] = useState(true);
const fetchData = useCallback(async () => {
try {
const allHosts = await getSSHHosts();
const filtered =
hostIds.length > 0
? allHosts.filter((h) => hostIds.includes(h.id))
: allHosts;
setHosts(filtered);
} catch {
/* ignore */
} finally {
setLoading(false);
}
}, [hostIds.join(",")]);
usePageVisibleInterval(() => {
void fetchData();
}, 30_000);
if (loading) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
{t("homepage.loading")}
</div>
);
}
if (hosts.length === 0) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
{t("homepage.noHosts")}
</div>
);
}
const accent = getAccentColor();
const isGrid = layout === "grid";
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<WidgetTitle title={widget.title} icon={<Zap size={11} />} />
<div
className={`${isGrid ? "grid grid-cols-2 content-start" : "flex flex-col"} gap-1.5 p-2 flex-1 overflow-auto`}
>
{hosts.map((host) => {
const online = host.status === "online";
const statusColor =
host.status === "online"
? accent
: host.status === "offline"
? "#ef4444"
: "#6b7280";
return (
<button
key={host.id}
onClick={() => openTab(host, connectionType)}
className="flex items-center gap-1.5 p-2 bg-muted/30 border border-border/40 hover:border-accent-brand/50 hover:bg-muted/60 transition-colors text-left overflow-hidden"
>
<TypeIcon type={connectionType} />
<span className="text-[10px] font-medium text-foreground truncate flex-1">
{host.name}
</span>
{showStatus && (
<span
className="w-1.5 h-1.5 rounded-full shrink-0"
style={{ background: statusColor }}
/>
)}
</button>
);
})}
</div>
</div>
);
}
registerWidget<SshQuickConnectConfig>({
id: "ssh_quick_connect",
name: "Quick Connect",
description: "One-click buttons to open SSH, file, or Docker tabs",
category: "system",
icon: <Zap size={14} />,
defaultConfig: {
hostIds: [],
connectionType: "terminal",
showStatus: true,
layout: "list",
},
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 7 },
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
component: SshQuickConnectWidget,
});
export { SshQuickConnectWidget };
@@ -8,8 +8,7 @@ import type {
} from "@/types/homepage-types";
import { GRID_SIZE } from "@/types/homepage-types";
import { CommandHistoryProvider } from "@/features/terminal/command-history/CommandHistoryContext";
import { Terminal } from "@/features/terminal/Terminal";
import type { TerminalHandle } from "@/features/terminal/Terminal";
import { Terminal, type TerminalHandle } from "@/features/terminal/Terminal";
import { getSSHHosts } from "@/api/ssh-host-management-api";
import type { SSHHostWithStatus } from "@/main-axios";
import { WidgetTitle } from "./WidgetTitle";
@@ -47,9 +47,14 @@ function SystemOverviewWidget({
const fetchData = async () => {
try {
if (showVersion) {
const info = await getVersionInfo(false);
// `checkRemote=false` makes /version return early with just
// {localVersion, status: "update_check_disabled"}, and the response has
// no `updateAvailable` field in either mode -- so the previous read was
// false twice over. `status` is what the endpoint actually answers with,
// and what the dashboard and profile badges already read.
const info = await getVersionInfo();
setVersion((info?.localVersion as string) ?? null);
setUpdateAvailable(Boolean(info?.updateAvailable));
setUpdateAvailable(info?.status === "requires_update");
}
if (showDbHealth) {
const health = await getDatabaseHealth();
@@ -93,7 +98,7 @@ function SystemOverviewWidget({
)}
{showVersion && updateAvailable && (
<InfoRow
label={t("homepage.overviewUpdate")}
label={t("homepage.overviewUpdateLabel")}
value={t("homepage.overviewUpdateAvailable")}
valueColor="#f97316"
/>
+13 -13
View File
@@ -2,6 +2,7 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { HostMetricsTab } from "@/features/host-metrics/HostMetricsTab.tsx";
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
interface HostMetricsAppProps {
hostId?: string;
@@ -11,26 +12,25 @@ const HostMetricsApp: React.FC<HostMetricsAppProps> = ({ hostId }) => {
const { t } = useTranslation();
return (
<FullScreenAppWrapper hostId={hostId}>
{(hostConfig, loading) => {
if (loading) {
{(hostConfig, phase) => {
if (phase === "loading") {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<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>
</div>
<div className="relative h-full w-full">
<ConnectionScreen
status="connecting"
message={t("hosts.loadingHost")}
/>
</div>
);
}
if (!hostConfig) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<p className="mb-4 text-red-500">{t("hosts.hostNotFound")}</p>
</div>
<div className="relative h-full w-full">
<ConnectionScreen
status="disconnected"
message={t("hosts.hostNotFound")}
/>
</div>
);
}
+393 -302
View File
@@ -1,3 +1,4 @@
import { getErrorMessage } from "../../lib/error-message.js";
/* eslint-disable react-hooks/exhaustive-deps */
import React from "react";
import { Separator } from "@/components/separator.tsx";
@@ -9,11 +10,17 @@ import {
stopMetricsPolling,
submitMetricsTOTP,
executeSnippet,
getSnippets,
logActivity,
sendMetricsHeartbeat,
getSSHHosts,
type ServerMetrics,
} from "@/main-axios.ts";
import { SnippetVariablesDialog } from "@/components/SnippetVariablesDialog";
import { useAreaPreferences } from "@/contexts/UiPreferencesContext";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import { hasSnippetInputs } from "@/lib/snippet-variables.ts";
import type { Snippet } from "@/types/ui-types.ts";
import { TOTPDialog } from "@/ssh/dialogs/TOTPDialog.tsx";
import { useTabsSafe } from "@/shell/TabContext.tsx";
import { useTranslation } from "react-i18next";
@@ -26,13 +33,14 @@ import {
defaultLayoutFromWidgets,
type HostMetricsLayout,
} from "@/types/host-metrics.ts";
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
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 { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
import { useConnectionRetry } from "@/lib/useConnectionRetry.ts";
import { runAdaptivePolling } from "@/lib/adaptive-polling.ts";
import type { LogEntry } from "@/types/connection-log.ts";
import {
CardGridCanvas,
@@ -51,6 +59,19 @@ import {
const HISTORY_LEN = 30;
function metricsChangeKey(data: ServerMetrics): string {
const bucket = (value: number | null | undefined) =>
value == null ? null : Math.round(value / 5) * 5;
return JSON.stringify({
cpu: bucket(data.cpu.percent),
memory: bucket(data.memory.percent),
disk: bucket(data.disk.percent),
running: data.processes?.running ?? null,
ports: data.ports?.ports?.length ?? 0,
firewall: data.firewall?.status ?? null,
});
}
interface QuickAction {
name: string;
snippetId: number;
@@ -99,11 +120,7 @@ function HostMetricsInner({
embedded = false,
}: HostMetricsProps): React.ReactElement {
const { t } = useTranslation();
const {
addLog,
clearLogs,
isExpanded: isConnectionLogExpanded,
} = useConnectionLog();
const { addLog, clearLogs } = useConnectionLog();
const { currentTab, removeTab } = useTabsSafe();
const [serverStatus, setServerStatus] = React.useState<"online" | "offline">(
@@ -116,7 +133,6 @@ function HostMetricsInner({
disk: [],
});
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
const [isLoadingMetrics, setIsLoadingMetrics] = React.useState(false);
const [isRefreshing, setIsRefreshing] = React.useState(false);
const [executingActions, setExecutingActions] = React.useState<Set<number>>(
new Set(),
@@ -129,11 +145,16 @@ function HostMetricsInner({
const [viewerSessionId, setViewerSessionId] = React.useState<string | null>(
null,
);
const [hasConnectionError, setHasConnectionError] = React.useState(false);
const [editMode, setEditMode] = React.useState(false);
const [runningAction, setRunningAction] = React.useState<{
action: QuickAction;
snippet: Snippet;
} | null>(null);
const { confirmWithToast } = useConfirmation();
const activityLoggedRef = React.useRef(false);
const activityLoggingRef = React.useRef(false);
const snippetsCacheRef = React.useRef<Snippet[] | null>(null);
const statsConfig = React.useMemo(
() => parseStatsConfig(currentHostConfig?.statsConfig),
@@ -145,10 +166,17 @@ function HostMetricsInner({
const hostId = currentHostConfig?.id ?? null;
const { layout, setLayout } = useHostMetricsPreferences(hostId);
const metricsPrefs = useAreaPreferences("hostMetrics");
const effectiveLayout: HostMetricsLayout = React.useMemo(() => {
// A saved layout is user-authored, so the preset never rewrites it -- it
// only decides the shape of the first layout a host gets.
if (layout) return layout;
return defaultLayoutFromWidgets(statsConfig.enabledWidgets ?? []);
}, [layout, statsConfig.enabledWidgets]);
return defaultLayoutFromWidgets(
statsConfig.enabledWidgets ?? [],
metricsPrefs.columns,
);
}, [layout, statsConfig.enabledWidgets, metricsPrefs.columns]);
// Only render/keep cards that are implemented (metric cards in Phase A).
const visibleSlots = React.useMemo(
@@ -291,24 +319,31 @@ function HostMetricsInner({
return;
}
let cancelled = false;
let lastStatus = serverStatus;
const fetchStatus = async () => {
try {
const res = await getServerStatusById(currentHostConfig.id);
const nextStatus = res?.status === "online" ? "online" : "offline";
if (!cancelled) {
setServerStatus(res?.status === "online" ? "online" : "offline");
setServerStatus(nextStatus);
}
const changed = nextStatus !== lastStatus;
lastStatus = nextStatus;
return changed;
} catch {
if (!cancelled) setServerStatus("offline");
throw new Error("Host status check failed");
}
};
fetchStatus();
const id = window.setInterval(
fetchStatus,
statsConfig.statusCheckInterval * 1000,
);
const minIntervalMs = statsConfig.statusCheckInterval * 1000;
const stop = runAdaptivePolling(fetchStatus, {
minIntervalMs,
maxIntervalMs: Math.min(120_000, minIntervalMs * 6),
stablePollsPerStep: 3,
});
return () => {
cancelled = true;
window.clearInterval(id);
stop();
};
}, [
currentHostConfig?.id,
@@ -317,98 +352,89 @@ function HostMetricsInner({
isActuallyVisible,
]);
React.useEffect(() => {
if (!metricsEnabled || !currentHostConfig?.id) return;
const stopMetricsPollingRef = React.useRef<(() => void) | null>(null);
let cancelled = false;
let pollingId: number | undefined;
if (isActuallyVisible && !metrics) setIsLoadingMetrics(true);
else if (!isActuallyVisible) setIsLoadingMetrics(false);
const fetchMetrics = React.useCallback(async (): Promise<void> => {
if (!currentHostConfig?.id) return;
if (currentHostConfig.authType === "none") {
toast.error(t("hostMetrics.noneAuthNotSupported"));
if (currentTab !== null) removeTab(currentTab);
throw new Error(t("hostMetrics.noneAuthNotSupported"));
}
const startMetrics = async () => {
if (cancelled) return;
if (currentHostConfig.authType === "none") {
toast.error(t("hostMetrics.noneAuthNotSupported"));
setIsLoadingMetrics(false);
if (currentTab !== null) removeTab(currentTab);
if (!totpVerified) {
addLog({
type: "info",
stage: "stats_connecting",
message: `Connecting to ${currentHostConfig.username}@${currentHostConfig.ip}:${currentHostConfig.port}`,
});
const result = await startMetricsPolling(currentHostConfig.id);
result?.connectionLogs?.forEach((log) =>
addLog(log as ConnectionLogPayload),
);
if (result.requires_totp) {
setTotpRequired(true);
setTotpSessionId(result.sessionId || null);
setTotpPrompt(result.prompt || "Verification code");
// The TOTP dialog gates further progress; retry loop pauses via `enabled`.
return;
}
if (result.viewerSessionId) setViewerSessionId(result.viewerSessionId);
}
const hadMetrics = metrics !== null;
if (!hadMetrics) setIsLoadingMetrics(true);
setHasConnectionError(false);
clearLogs();
const data = await getServerMetricsById(currentHostConfig.id);
if (!data) {
throw new Error(t("hostMetrics.connectionFailed"));
}
setMetrics(data);
setServerStatus("online");
logServerActivity();
addLog({
type: "success",
stage: "connected",
message: t("terminal.connected"),
});
let signature = metricsChangeKey(data);
const minIntervalMs = statsConfig.metricsInterval * 1000;
stopMetricsPollingRef.current?.();
stopMetricsPollingRef.current = runAdaptivePolling(
async () => {
const next = await getServerMetricsById(currentHostConfig.id);
if (!next) throw new Error(t("hostMetrics.connectionFailed"));
const nextSignature = metricsChangeKey(next);
const changed = nextSignature !== signature;
signature = nextSignature;
setMetrics(next);
pushHistory(next);
return changed;
},
{
minIntervalMs,
maxIntervalMs: Math.min(120_000, minIntervalMs * 6),
stablePollsPerStep: 3,
},
{ runImmediately: false },
);
}, [
currentHostConfig,
totpVerified,
statsConfig.metricsInterval,
addLog,
t,
currentTab,
removeTab,
pushHistory,
]);
const metricsRetry = useConnectionRetry({
connect: async () => {
try {
if (!totpVerified) {
addLog({
type: "info",
stage: "stats_connecting",
message: `Connecting to ${currentHostConfig.username}@${currentHostConfig.ip}:${currentHostConfig.port}`,
});
const result = await startMetricsPolling(currentHostConfig.id);
if (cancelled) return;
result?.connectionLogs?.forEach((log) =>
addLog(log as ConnectionLogPayload),
);
if (result.requires_totp) {
setTotpRequired(true);
setTotpSessionId(result.sessionId || null);
setTotpPrompt(result.prompt || "Verification code");
setIsLoadingMetrics(false);
return;
}
if (result.viewerSessionId)
setViewerSessionId(result.viewerSessionId);
}
// Poll until first metrics arrive. The backend's initial SSH collection
// can take up to ~60s (queue timeout), so we wait up to 90s total.
let retry = 0;
let data: ServerMetrics | null = null;
const maxRetries = 30;
while (retry < maxRetries && !cancelled) {
try {
data = await getServerMetricsById(currentHostConfig.id);
if (data) break;
} catch {
// non-404 error — keep retrying
}
retry++;
if (retry < maxRetries && !cancelled) {
await new Promise((r) => setTimeout(r, 3000));
}
}
if (cancelled) return;
if (data) {
setMetrics(data);
setServerStatus("online");
if (!hadMetrics) {
setIsLoadingMetrics(false);
logServerActivity();
}
} else {
throw new Error(t("hostMetrics.connectionFailed"));
}
pollingId = window.setInterval(async () => {
if (cancelled) return;
try {
const next = await getServerMetricsById(currentHostConfig.id);
if (!cancelled && next) {
setMetrics(next);
pushHistory(next);
}
} catch {
/* keep prior */
}
}, statsConfig.metricsInterval * 1000);
await fetchMetrics();
if (!totpRequired) metricsRetry.markConnected();
} catch (error: unknown) {
if (cancelled) return;
const logError = error as ConnectionLogError;
setIsLoadingMetrics(false);
setHasConnectionError(true);
if (logError.connectionLogs) {
logError.connectionLogs.forEach((log) => addLog(log));
} else {
@@ -421,14 +447,28 @@ function HostMetricsInner({
: t("hostMetrics.connectionFailed"),
});
}
metricsRetry.markFailed();
}
};
},
enabled:
isActuallyVisible &&
metricsEnabled &&
!totpRequired &&
!!currentHostConfig?.id,
autoStart: false,
});
const metricsRetryRef = React.useRef(metricsRetry);
metricsRetryRef.current = metricsRetry;
React.useEffect(() => {
if (!metricsEnabled || !currentHostConfig?.id) return;
let cancelled = false;
const stopMetrics = async () => {
if (pollingId) {
window.clearInterval(pollingId);
pollingId = undefined;
}
stopMetricsPollingRef.current?.();
stopMetricsPollingRef.current = null;
if (currentHostConfig?.id) {
await stopMetricsPolling(
currentHostConfig.id,
@@ -438,8 +478,11 @@ function HostMetricsInner({
};
const debounce = setTimeout(() => {
if (cancelled) return;
if (isActuallyVisible) {
if (!hasConnectionError) startMetrics();
clearLogs();
metricsRetryRef.current.reset();
metricsRetryRef.current.retryNow();
} else {
stopMetrics();
}
@@ -448,19 +491,21 @@ function HostMetricsInner({
return () => {
cancelled = true;
clearTimeout(debounce);
if (pollingId) window.clearInterval(pollingId);
stopMetricsPollingRef.current?.();
stopMetricsPollingRef.current = null;
if (currentHostConfig?.id) {
stopMetricsPolling(currentHostConfig.id).catch(() => {});
}
};
}, [
currentHostConfig?.id,
isActuallyVisible,
metricsEnabled,
statsConfig.metricsInterval,
totpVerified,
hasConnectionError,
]);
}, [currentHostConfig?.id, isActuallyVisible, metricsEnabled]);
// After a successful TOTP submit, resume the connect flow immediately.
React.useEffect(() => {
if (totpVerified && !totpRequired) {
metricsRetryRef.current.reset();
metricsRetryRef.current.retryNow();
}
}, [totpVerified, totpRequired]);
const wrapperStyle: React.CSSProperties = embedded
? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" }
@@ -472,9 +517,8 @@ function HostMetricsInner({
const handleRefresh = async () => {
if (!currentHostConfig?.id) return;
if (hasConnectionError) {
setHasConnectionError(false);
clearLogs();
if (metricsRetry.status !== "connected") {
metricsRetry.retryNow();
return;
}
try {
@@ -493,183 +537,205 @@ function HostMetricsInner({
}
};
async function executeQuickAction(
action: QuickAction,
inputValues: Record<string, string>,
) {
if (!currentHostConfig) return;
setExecutingActions((prev) => new Set(prev).add(action.snippetId));
toast.loading(
t("hostMetrics.executingQuickAction", { name: action.name }),
{
id: `quick-action-${action.snippetId}`,
},
);
try {
const result = await executeSnippet(
action.snippetId,
currentHostConfig.id,
Object.keys(inputValues).length > 0 ? inputValues : undefined,
);
if (result.success) {
toast.success(
t("hostMetrics.quickActionSuccess", { name: action.name }),
{
id: `quick-action-${action.snippetId}`,
description: result.output?.substring(0, 200),
duration: 5000,
},
);
} else {
toast.error(t("hostMetrics.quickActionFailed", { name: action.name }), {
id: `quick-action-${action.snippetId}`,
description: result.error || result.output,
duration: 5000,
});
}
} catch (error) {
toast.error(t("hostMetrics.quickActionError", { name: action.name }), {
id: `quick-action-${action.snippetId}`,
description: getErrorMessage(error),
duration: 5000,
});
} finally {
setExecutingActions((prev) => {
const next = new Set(prev);
next.delete(action.snippetId);
return next;
});
}
}
function runQuickActionWithConfirm(
action: QuickAction,
inputValues: Record<string, string>,
) {
const shouldConfirm =
localStorage.getItem("confirmSnippetExecution") === "true";
const run = () => void executeQuickAction(action, inputValues);
if (!shouldConfirm) {
run();
return;
}
confirmWithToast(
t("newUi.sidebar.snippets.confirmRunMessage", { name: action.name }),
run,
t("newUi.sidebar.snippets.confirmRunButton"),
t("newUi.sidebar.snippets.cancel"),
{ confirmOnEnter: true, duration: 6000 },
);
}
async function runQuickAction(action: QuickAction) {
if (!currentHostConfig) return;
try {
if (!snippetsCacheRef.current) {
snippetsCacheRef.current =
(await getSnippets()) as unknown as Snippet[];
}
const snippet = snippetsCacheRef.current?.find(
(s) => s.id === action.snippetId,
);
if (snippet && hasSnippetInputs(snippet.content)) {
setRunningAction({ action, snippet });
return;
}
} catch {
// fall through and run with no inputs if the snippet lookup fails
}
runQuickActionWithConfirm(action, {});
}
const showCards =
metricsEnabled && !isLoadingMetrics && metrics && !hasConnectionError;
metricsEnabled && metricsRetry.status === "connected" && metrics;
const showOffline =
metricsEnabled &&
!isLoadingMetrics &&
metricsRetry.status !== "connecting" &&
!metrics &&
serverStatus === "offline" &&
!hasConnectionError;
serverStatus === "offline";
return (
<div
style={wrapperStyle}
className="relative flex flex-col overflow-hidden"
>
<div
className="flex min-h-0 flex-1 flex-col overflow-hidden"
style={{
visibility:
hasConnectionError && isConnectionLogExpanded
? "hidden"
: "visible",
}}
>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="flex flex-1 flex-col overflow-x-hidden overflow-y-auto">
{!totpRequired && !isLoadingMetrics && !hasConnectionError && (
<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="flex size-10 shrink-0 items-center justify-center border border-border bg-muted">
<Server className="size-5 text-accent-brand" />
{!totpRequired &&
(metricsRetry.status === "connected" || showOffline) && (
<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="flex size-10 shrink-0 items-center justify-center border border-border bg-muted">
<Server className="size-5 text-accent-brand" />
</div>
<h1 className="text-lg font-bold md:text-2xl">{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 items-center gap-0">
{currentHostConfig?.quickActions &&
currentHostConfig.quickActions.length > 0 && (
<>
<div className="mr-3 flex flex-wrap gap-2">
{currentHostConfig.quickActions.map(
(action, index) => {
const isExecuting = executingActions.has(
action.snippetId,
);
return (
<Button
key={index}
variant="outline"
size="sm"
className="h-8 text-xs font-semibold"
disabled={isExecuting}
onClick={() => void runQuickAction(action)}
>
{isExecuting ? (
<>
<RefreshCw className="mr-1 size-3 animate-spin" />
{action.name}
</>
) : (
action.name
)}
</Button>
);
},
)}
</div>
<Separator
orientation="vertical"
className="mx-3 h-8"
/>
</>
)}
{editMode && (
<>
<div className="mr-3 flex flex-wrap gap-2">
{currentHostConfig.quickActions.map((action, index) => {
const isExecuting = executingActions.has(
action.snippetId,
);
return (
<Button
key={index}
variant="outline"
size="sm"
className="h-8 text-xs font-semibold"
disabled={isExecuting}
onClick={async () => {
if (!currentHostConfig) return;
setExecutingActions((prev) =>
new Set(prev).add(action.snippetId),
);
toast.loading(
t("hostMetrics.executingQuickAction", {
name: action.name,
}),
{ id: `quick-action-${action.snippetId}` },
);
try {
const result = await executeSnippet(
action.snippetId,
currentHostConfig.id,
);
if (result.success) {
toast.success(
t("hostMetrics.quickActionSuccess", {
name: action.name,
}),
{
id: `quick-action-${action.snippetId}`,
description: result.output?.substring(
0,
200,
),
duration: 5000,
},
);
} else {
toast.error(
t("hostMetrics.quickActionFailed", {
name: action.name,
}),
{
id: `quick-action-${action.snippetId}`,
description:
result.error || result.output,
duration: 5000,
},
);
}
} catch (error) {
toast.error(
t("hostMetrics.quickActionError", {
name: action.name,
}),
{
id: `quick-action-${action.snippetId}`,
description:
error instanceof Error
? error.message
: "Unknown error",
duration: 5000,
},
);
} finally {
setExecutingActions((prev) => {
const next = new Set(prev);
next.delete(action.snippetId);
return next;
});
}
}}
>
{isExecuting ? (
<>
<RefreshCw className="mr-1 size-3 animate-spin" />
{action.name}
</>
) : (
action.name
)}
</Button>
);
})}
</div>
<Separator orientation="vertical" className="mx-3 h-8" />
<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>
</>
)}
{editMode && (
<>
<ColumnCountStepper
columns={effectiveLayout.columns}
onChange={(columns) =>
setLayout({ ...effectiveLayout, columns })
}
<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="ml-1 gap-2 font-semibold"
>
<RefreshCw
className={`size-3.5 ${isRefreshing ? "animate-spin" : ""}`}
/>
<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="ml-1 gap-2 font-semibold"
>
<RefreshCw
className={`size-3.5 ${isRefreshing ? "animate-spin" : ""}`}
/>
{t("hostMetrics.refresh")}
</Button>
{t("hostMetrics.refresh")}
</Button>
</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">
@@ -680,20 +746,6 @@ function HostMetricsInner({
</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
@@ -714,10 +766,33 @@ function HostMetricsInner({
)}
</div>
{metricsEnabled && (
<SimpleLoader
visible={isLoadingMetrics && !metrics && !isConnectionLogExpanded}
{metricsEnabled && !totpRequired && (
<ConnectionScreen
status={showOffline ? "connected" : metricsRetry.status}
message={t("hostMetrics.connecting")}
attempt={metricsRetry.attempt}
maxAttempts={metricsRetry.maxAttempts}
nextRetryInMs={metricsRetry.nextRetryInMs}
onManualRetry={metricsRetry.retryNow}
logPosition={
metricsRetry.status === "error" ||
metricsRetry.status === "disconnected"
? "top"
: "bottom"
}
emptyState={
showOffline ? (
<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>
) : undefined
}
/>
)}
</div>
@@ -729,12 +804,28 @@ function HostMetricsInner({
onCancel={handleTOTPCancel}
backgroundColor="var(--bg-canvas)"
/>
<ConnectionLog
isConnecting={isLoadingMetrics}
isConnected={serverStatus === "online" && !hasConnectionError}
hasConnectionError={hasConnectionError}
position={hasConnectionError ? "top" : "bottom"}
/>
{runningAction && (
<SnippetVariablesDialog
snippet={runningAction.snippet}
host={
currentHostConfig
? {
ip: currentHostConfig.ip,
username: currentHostConfig.username,
port: currentHostConfig.port,
name: currentHostConfig.name,
}
: null
}
onCancel={() => setRunningAction(null)}
onConfirm={(_resolvedContent, inputValues) => {
const action = runningAction.action;
setRunningAction(null);
runQuickActionWithConfirm(action, inputValues);
}}
/>
)}
</div>
);
}
@@ -1,222 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { LineChart, type LineChartSeries } from "@/components/charts/LineChart";
import {
getMetricsHistory,
type MetricsHistoryRow,
} from "@/api/host-metrics-api";
export type HistoryRange = "1h" | "6h" | "24h" | "7d" | "30d";
interface MetricsHistorySectionProps {
hostId: number;
timeRange: HistoryRange;
}
function computeNetRates(rows: MetricsHistoryRow[]): {
rxRates: Array<number | null>;
txRates: Array<number | null>;
} {
const rxRates: Array<number | null> = [];
const txRates: Array<number | null> = [];
for (let i = 0; i < rows.length; i++) {
if (i === 0) {
rxRates.push(null);
txRates.push(null);
continue;
}
const prev = rows[i - 1];
const curr = rows[i];
const dtSec =
(new Date(curr.ts).getTime() - new Date(prev.ts).getTime()) / 1000;
if (
dtSec < 0.5 ||
prev.net_rx_bytes == null ||
curr.net_rx_bytes == null ||
prev.net_tx_bytes == null ||
curr.net_tx_bytes == null
) {
rxRates.push(null);
txRates.push(null);
continue;
}
const rxDelta = curr.net_rx_bytes - prev.net_rx_bytes;
const txDelta = curr.net_tx_bytes - prev.net_tx_bytes;
rxRates.push(rxDelta < 0 ? null : rxDelta / dtSec);
txRates.push(txDelta < 0 ? null : txDelta / dtSec);
}
return { rxRates, txRates };
}
function formatBytes(bps: number): string {
if (bps >= 1024 * 1024) return `${(bps / 1024 / 1024).toFixed(1)} MB/s`;
if (bps >= 1024) return `${(bps / 1024).toFixed(1)} KB/s`;
return `${Math.round(bps)} B/s`;
}
function maxOrZero(arr: Array<number | null>): number {
return arr.reduce<number>((m, v) => (v != null && v > m ? v : m), 0);
}
export function MetricsHistorySection({
hostId,
timeRange,
}: MetricsHistorySectionProps) {
const { t } = useTranslation();
const [rows, setRows] = useState<MetricsHistoryRow[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
getMetricsHistory(hostId, { range: timeRange })
.then((res) => {
if (!cancelled) {
setRows(res.rows);
setLoading(false);
}
})
.catch((err) => {
if (!cancelled) {
setError(
err instanceof Error ? err.message : "Failed to load history",
);
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [hostId, timeRange]);
const timestamps = rows?.map((r) => r.ts) ?? [];
const cpuSeries: LineChartSeries = {
key: "cpu",
label: t("hostMetrics.cpuUsage"),
color: "var(--accent-brand)",
data: rows?.map((r) => r.cpu_percent) ?? [],
};
const memSeries: LineChartSeries = {
key: "mem",
label: t("hostMetrics.memoryUsage"),
color: "var(--chart-3)",
data: rows?.map((r) => r.mem_percent) ?? [],
};
const diskSeries: LineChartSeries = {
key: "disk",
label: t("hostMetrics.diskUsage"),
color: "var(--chart-4)",
data: rows?.map((r) => r.disk_percent) ?? [],
};
const { rxRates, txRates } = rows
? computeNetRates(rows)
: { rxRates: [], txRates: [] };
const netMax = Math.max(maxOrZero(rxRates), maxOrZero(txRates), 1024);
const rxSeries: LineChartSeries = {
key: "rx",
label: t("metricsHistory.download"),
color: "var(--chart-2)",
data: rxRates,
};
const txSeries: LineChartSeries = {
key: "tx",
label: t("metricsHistory.upload"),
color: "var(--chart-5)",
data: txRates,
};
const hasData = rows !== null && rows.length > 0;
const hasNetData = rxRates.some((v) => v !== null);
if (loading) {
return (
<div className="flex flex-col gap-4 px-3 pb-3">
{["cpu", "mem", "disk", "net"].map((k) => (
<div key={k} className="border border-border bg-card p-4">
<div className="mb-3 h-3 w-24 animate-pulse rounded bg-muted" />
<div className="h-40 animate-pulse rounded bg-muted" />
</div>
))}
</div>
);
}
if (error) {
return (
<div className="mx-3 flex h-40 items-center justify-center border border-border bg-card text-sm text-destructive">
{error}
</div>
);
}
if (!hasData) {
return (
<div className="mx-3 flex h-40 items-center justify-center border border-border bg-card text-sm text-muted-foreground">
{t("metricsHistory.noData")}
</div>
);
}
return (
<div className="flex flex-col gap-4 px-3 pb-3">
<div className="border border-border bg-card p-4">
<div className="mb-3 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("hostMetrics.cpuUsage")}
</div>
<LineChart
series={[cpuSeries]}
timestamps={timestamps}
domain={[0, 100]}
yFormatter={(v) => `${v.toFixed(0)}%`}
height={160}
/>
</div>
<div className="border border-border bg-card p-4">
<div className="mb-3 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("hostMetrics.memoryUsage")}
</div>
<LineChart
series={[memSeries]}
timestamps={timestamps}
domain={[0, 100]}
yFormatter={(v) => `${v.toFixed(0)}%`}
height={160}
/>
</div>
<div className="border border-border bg-card p-4">
<div className="mb-3 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("hostMetrics.diskUsage")}
</div>
<LineChart
series={[diskSeries]}
timestamps={timestamps}
domain={[0, 100]}
yFormatter={(v) => `${v.toFixed(0)}%`}
height={160}
/>
</div>
<div className="border border-border bg-card p-4">
<div className="mb-3 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("metricsHistory.network")}
</div>
{hasNetData ? (
<LineChart
series={[rxSeries, txSeries]}
timestamps={timestamps}
domain={[0, netMax]}
yFormatter={formatBytes}
height={160}
/>
) : (
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">
{t("metricsHistory.noData")}
</div>
)}
</div>
</div>
);
}
@@ -37,7 +37,9 @@ export function FilesystemPicker({
)}
title={t("hostMetrics.selectFilesystem")}
>
<span className="truncate">{selected?.mount ?? value ?? "-"}</span>
<span className="truncate">
{selected?.label || selected?.mount || value || "-"}
</span>
<ChevronDown className="size-3 shrink-0" />
</DropdownMenuTrigger>
<DropdownMenuContent
@@ -57,8 +59,13 @@ export function FilesystemPicker({
)}
/>
<span className="min-w-0 flex-1 truncate font-medium">
{fs.mount}
{fs.label || fs.mount}
</span>
{fs.label && (
<span className="max-w-24 truncate font-mono text-[10px] text-muted-foreground">
{fs.mount}
</span>
)}
<span className="ml-auto shrink-0 whitespace-nowrap text-[10px] tabular-nums text-muted-foreground">
{fs.usedHuman && fs.totalHuman
? `${fs.usedHuman}/${fs.totalHuman}`
@@ -161,9 +161,16 @@ export function NetworkCard({
</div>
<div className="flex justify-between font-mono text-[10px] text-muted-foreground">
<span className="truncate">{iface.ip}</span>
{(iface.rx || iface.tx) && (
{(iface.rxRateBps != null || iface.txRateBps != null) && (
<span className="shrink-0">
&#8595; {iface.rx ?? "—"} / &#8593; {iface.tx ?? "—"}
&#8595;{" "}
{iface.rxRateBps == null
? "—"
: formatBytes(iface.rxRateBps)}{" "}
/ &#8593;{" "}
{iface.txRateBps == null
? "—"
: formatBytes(iface.txRateBps)}
</span>
)}
</div>
+5 -2
View File
@@ -1,7 +1,10 @@
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 {
defaultColSpanFor,
defaultHeightFor,
type HostMetricsCardId,
} from "@/types/host-metrics";
import { CpuCard } from "./CpuCard";
import { MemoryCard } from "./MemoryCard";
import { DiskCard } from "./DiskCard";
@@ -3,8 +3,7 @@ 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 { managerPost, type FirewallChain } from "@/main-axios";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { ManagerSearch } from "./ManagerToolbar";
@@ -1,9 +1,10 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, 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";
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling";
interface LogFiles {
common: string[];
@@ -26,6 +27,8 @@ export function LogViewerCard({ hostId }: { hostId: number | null }) {
const [error, setError] = useState<string | null>(null);
const [files, setFiles] = useState<string[]>([]);
const bodyRef = useRef<HTMLPreElement | null>(null);
const contentRef = useRef(content);
contentRef.current = content;
// Load the host's actual log files once.
useEffect(() => {
@@ -42,44 +45,56 @@ export function LogViewerCard({ hostId }: { hostId: number | null }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hostId]);
const fetchLog = async () => {
if (hostId == null) return;
const fetchLog = useCallback(async () => {
if (hostId == null) return false;
const params: Record<string, string | number> = { lines };
if (mode === "unit") {
if (!unit.trim()) return;
if (!unit.trim()) return false;
params.unit = unit.trim();
} else {
const p = customPath.trim() || path;
if (!p) return;
if (!p) return false;
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;
});
const next = res.content || "";
const changed = next !== contentRef.current;
if (changed) {
contentRef.current = next;
setContent(next);
requestAnimationFrame(() => {
if (bodyRef.current)
bodyRef.current.scrollTop = bodyRef.current.scrollHeight;
});
}
return changed;
} catch (e) {
setError(extractError(e).message);
} finally {
setLoading(false);
}
};
}, [customPath, hostId, lines, mode, path, unit]);
useEffect(() => {
fetchLog();
void fetchLog();
// Custom paths are applied on blur/manual refresh, not on every keystroke.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, path, unit, lines]);
}, [hostId, 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]);
useAdaptivePolling(
fetchLog,
{
minIntervalMs: 3_000,
maxIntervalMs: 18_000,
stablePollsPerStep: 2,
maxRequestDutyCycle: 0.2,
},
follow,
{ runImmediately: false },
);
const shown = useMemo(() => {
if (!grep) return content;
@@ -0,0 +1,110 @@
import { useCallback, useEffect, useRef } from "react";
import { FitAddon } from "@xterm/addon-fit";
import { useXTerm } from "react-xtermjs";
import { useTheme } from "@/components/theme-provider";
import { resolveTermixThemeColors } from "@/features/terminal/terminal-theme";
import { DEFAULT_TERMINAL_CONFIG, TERMINAL_FONTS } from "@/lib/terminal-themes";
import { ensureTerminalFontsLoaded } from "@/features/terminal/terminal-global-styles";
export function LocalTerminal({
instanceId,
isVisible,
}: {
instanceId: string;
isVisible: boolean;
}) {
const { theme: appTheme } = useTheme();
const { instance: terminal, ref: xtermRef } = useXTerm();
const fitAddonRef = useRef<FitAddon | null>(null);
const sessionIdRef = useRef<string | null>(null);
const fit = useCallback(() => {
const fitAddon = fitAddonRef.current;
const sessionId = sessionIdRef.current;
if (!terminal || !fitAddon) return;
fitAddon.fit();
if (sessionId) {
window.electronAPI.resizeLocalTerminal(
sessionId,
terminal.cols,
terminal.rows,
);
}
}, [terminal]);
useEffect(() => {
if (!terminal) return;
const colors = resolveTermixThemeColors("termix", appTheme);
const font = TERMINAL_FONTS.find(
(item) => item.value === DEFAULT_TERMINAL_CONFIG.fontFamily,
);
ensureTerminalFontsLoaded(font?.value ?? TERMINAL_FONTS[0].value);
terminal.options.theme = colors;
terminal.options.fontFamily = font?.fallback ?? TERMINAL_FONTS[0].fallback;
terminal.options.fontSize = DEFAULT_TERMINAL_CONFIG.fontSize;
}, [appTheme, terminal]);
useEffect(() => {
if (!terminal || !window.electronAPI?.isElectron) return;
const fitAddon = new FitAddon();
fitAddonRef.current = fitAddon;
terminal.loadAddon(fitAddon);
fitAddon.fit();
let disposed = false;
let removeData = () => {};
let removeExit = () => {};
const input = terminal.onData((data) => {
const sessionId = sessionIdRef.current;
if (sessionId) window.electronAPI.writeLocalTerminal(sessionId, data);
});
window.electronAPI
.startLocalTerminal({ cols: terminal.cols, rows: terminal.rows })
.then(({ sessionId }) => {
if (disposed) {
window.electronAPI.closeLocalTerminal(sessionId);
return;
}
sessionIdRef.current = sessionId;
removeData = window.electronAPI.onLocalTerminalData(sessionId, (data) =>
terminal.write(data),
);
removeExit = window.electronAPI.onLocalTerminalExit(
sessionId,
(exitCode) => {
sessionIdRef.current = null;
terminal.write(
`\r\n\x1b[33mProcess exited (${exitCode})\x1b[0m\r\n`,
);
},
);
return window.electronAPI.readyLocalTerminal(sessionId);
})
.catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
terminal.write(`\r\n\x1b[31m${message}\x1b[0m\r\n`);
});
const observer = new ResizeObserver(() => fit());
if (xtermRef.current) observer.observe(xtermRef.current);
return () => {
disposed = true;
observer.disconnect();
input.dispose();
removeData();
removeExit();
const sessionId = sessionIdRef.current;
sessionIdRef.current = null;
if (sessionId) window.electronAPI.closeLocalTerminal(sessionId);
fitAddonRef.current = null;
fitAddon.dispose();
};
}, [fit, instanceId, terminal, xtermRef]);
useEffect(() => {
if (isVisible) requestAnimationFrame(fit);
}, [fit, isVisible]);
return <div ref={xtermRef} className="h-full w-full bg-background p-2" />;
}
@@ -0,0 +1,261 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Box, Search, Server as ServerIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Input } from "@/components/input";
import type { ProxmoxGuestSummary } from "@/types/proxmox";
type StatusFilter = "all" | "running" | "stopped";
type TypeFilter = "all" | "qemu" | "lxc";
function formatUptime(seconds: number | null): string {
if (seconds === null || seconds <= 0) return "-";
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
function UsageCell({
percent,
usedGiB,
totalGiB,
}: {
percent: number | null;
usedGiB?: number | null;
totalGiB?: number | null;
}) {
if (percent === null) {
return <span className="text-[11px] text-muted-foreground">-</span>;
}
const clamped = Math.min(100, Math.max(0, percent));
const barColor =
clamped >= 90
? "bg-red-500"
: clamped >= 75
? "bg-yellow-500"
: "bg-accent-brand";
return (
<div className="flex min-w-[92px] flex-col gap-1">
<div className="flex items-baseline justify-between gap-2">
<span className="text-xs font-semibold tabular-nums">
{clamped.toFixed(0)}%
</span>
{usedGiB != null && totalGiB != null && (
<span className="text-[10px] tabular-nums text-muted-foreground">
{usedGiB.toFixed(1)}/{totalGiB.toFixed(1)}G
</span>
)}
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className={cn("h-full rounded-full", barColor)}
style={{ width: `${clamped}%` }}
/>
</div>
</div>
);
}
export function GuestTable({ guests }: { guests: ProxmoxGuestSummary[] }) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all");
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return guests
.filter(
(g) =>
statusFilter === "all" ||
(statusFilter === "running") === (g.status === "running"),
)
.filter((g) => typeFilter === "all" || g.type === typeFilter)
.filter(
(g) =>
!q || g.name.toLowerCase().includes(q) || String(g.vmid).includes(q),
)
.sort((a, b) => {
if (a.status === b.status) return a.name.localeCompare(b.name);
return a.status === "running" ? -1 : 1;
});
}, [guests, query, statusFilter, typeFilter]);
const running = guests.filter((g) => g.status === "running").length;
return (
<div className="flex min-h-[420px] flex-1 flex-col overflow-hidden border border-border bg-card">
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border px-3 py-2.5">
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{t("proxmoxStats.guestsSummary")}
</span>
<span className="text-[11px] text-muted-foreground">
{t("proxmoxStats.guestCounts", { running, total: guests.length })}
</span>
<div className="ml-auto flex items-center gap-1.5">
<div className="relative">
<Search className="pointer-events-none absolute left-2 top-1/2 size-3 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("proxmoxStats.searchGuests")}
className="h-7 w-40 pl-6 text-xs"
/>
</div>
<div className="flex overflow-hidden rounded-none border border-border">
{(["all", "qemu", "lxc"] as TypeFilter[]).map((f) => (
<button
key={f}
onClick={() => setTypeFilter(f)}
className={cn(
"px-2 py-1 text-[10px] font-semibold uppercase tracking-wide transition-colors",
typeFilter === f
? "bg-accent-brand text-white"
: "bg-background text-muted-foreground hover:bg-muted",
)}
>
{f === "all"
? t("proxmoxStats.filterAll")
: f === "qemu"
? t("proxmoxStats.filterVm")
: t("proxmoxStats.filterLxc")}
</button>
))}
</div>
<div className="flex overflow-hidden rounded-none border border-border">
{(["all", "running", "stopped"] as StatusFilter[]).map((f) => (
<button
key={f}
onClick={() => setStatusFilter(f)}
className={cn(
"px-2 py-1 text-[10px] font-semibold uppercase tracking-wide transition-colors",
statusFilter === f
? "bg-accent-brand text-white"
: "bg-background text-muted-foreground hover:bg-muted",
)}
>
{f === "all"
? t("proxmoxStats.filterAll")
: f === "running"
? t("proxmoxStats.running")
: t("proxmoxStats.stopped")}
</button>
))}
</div>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto thin-scrollbar">
{guests.length === 0 ? (
<div className="flex h-full items-center justify-center py-16 text-xs text-muted-foreground">
{t("proxmoxStats.noGuests")}
</div>
) : filtered.length === 0 ? (
<div className="flex h-full items-center justify-center py-16 text-xs text-muted-foreground">
{t("proxmoxStats.noGuestsMatch")}
</div>
) : (
<table className="w-full border-collapse text-xs">
<thead className="sticky top-0 z-10 bg-card">
<tr className="border-b border-border text-[10px] uppercase tracking-wider text-muted-foreground">
<th className="px-3 py-2 text-left font-semibold">
{t("proxmoxStats.name")}
</th>
<th className="px-3 py-2 text-left font-semibold">
{t("proxmoxStats.status")}
</th>
<th className="px-3 py-2 text-left font-semibold">ID</th>
<th className="px-3 py-2 text-left font-semibold">
{t("proxmoxStats.cpu")}
</th>
<th className="px-3 py-2 text-left font-semibold">
{t("proxmoxStats.mem")}
</th>
<th className="px-3 py-2 text-left font-semibold">
{t("proxmoxStats.disk")}
</th>
<th className="px-3 py-2 text-left font-semibold">
{t("proxmoxStats.uptime")}
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{filtered.map((guest) => (
<tr
key={`${guest.type}-${guest.vmid}`}
className="transition-colors hover:bg-muted/40"
>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
{guest.type === "lxc" ? (
<Box className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<ServerIcon className="size-3.5 shrink-0 text-muted-foreground" />
)}
<span className="min-w-0 truncate font-semibold">
{guest.name}
</span>
<span className="shrink-0 rounded-full border border-border px-1.5 py-0.5 text-[9px] uppercase text-muted-foreground">
{guest.type === "lxc" ? "LXC" : "VM"}
</span>
</div>
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-1.5">
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
guest.status === "running"
? "bg-green-500"
: "bg-muted-foreground/50",
)}
/>
<span
className={cn(
"font-medium",
guest.status === "running"
? "text-foreground"
: "text-muted-foreground",
)}
>
{guest.status === "running"
? t("proxmoxStats.running")
: t("proxmoxStats.stopped")}
</span>
</div>
</td>
<td className="px-3 py-2 font-mono text-muted-foreground">
{guest.vmid}
</td>
<td className="px-3 py-2">
<UsageCell percent={guest.cpuPercent} />
</td>
<td className="px-3 py-2">
<UsageCell
percent={guest.memPercent}
usedGiB={guest.memUsedGiB}
totalGiB={guest.memTotalGiB}
/>
</td>
<td className="px-3 py-2">
<UsageCell
percent={guest.diskPercent}
usedGiB={guest.diskUsedGiB}
totalGiB={guest.diskTotalGiB}
/>
</td>
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
{formatUptime(guest.uptimeSeconds)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}
@@ -0,0 +1,147 @@
import { useTranslation } from "react-i18next";
import { Cpu, MemoryStick, HardDrive, Clock, Server } from "lucide-react";
import { cn } from "@/lib/utils";
import type { ProxmoxNodeStats } from "@/types/proxmox";
function Tile({
icon,
label,
percent,
detail,
sparkline,
}: {
icon: React.ReactNode;
label: string;
percent: number | null;
detail?: string | null;
sparkline?: number[];
}) {
const clamped = percent === null ? null : Math.min(100, Math.max(0, percent));
const barColor =
clamped === null
? "bg-muted-foreground/30"
: clamped >= 90
? "bg-red-500"
: clamped >= 75
? "bg-yellow-500"
: "bg-accent-brand";
return (
<div className="flex min-w-0 flex-1 flex-col gap-2 border border-border bg-card px-4 py-3">
<div className="flex items-center gap-2 text-muted-foreground">
{icon}
<span className="text-[10px] font-bold uppercase tracking-widest">
{label}
</span>
</div>
<div className="flex items-baseline justify-between gap-2">
<span className="text-2xl font-bold tabular-nums">
{clamped === null ? "N/A" : `${clamped.toFixed(0)}%`}
</span>
{detail && (
<span className="truncate text-[11px] text-muted-foreground">
{detail}
</span>
)}
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className={cn("h-full rounded-full transition-all", barColor)}
style={{ width: `${clamped ?? 0}%` }}
/>
</div>
{sparkline && sparkline.length > 1 && <MiniSparkline data={sparkline} />}
</div>
);
}
function MiniSparkline({ data }: { data: number[] }) {
const w = 100;
const h = 20;
const max = 100;
const points = data
.map((v, i) => {
const x = (i / (data.length - 1)) * w;
const y = h - (Math.min(100, Math.max(0, v)) / max) * h;
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(" ");
return (
<svg viewBox={`0 0 ${w} ${h}`} className="h-5 w-full text-accent-brand/70">
<polyline
points={points}
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinejoin="round"
strokeLinecap="round"
/>
</svg>
);
}
export function NodeSummaryStrip({
node,
histories,
}: {
node: ProxmoxNodeStats | undefined;
histories: { cpu: number[]; memory: number[]; disk: number[] };
}) {
const { t } = useTranslation();
return (
<div className="flex flex-wrap gap-2">
<Tile
icon={<Cpu className="size-3.5" />}
label={t("proxmoxStats.cpuUsage")}
percent={node?.cpu.percent ?? null}
detail={
node?.cpu.cores != null
? t("proxmoxStats.cpuCores", { count: node.cpu.cores })
: undefined
}
sparkline={histories.cpu}
/>
<Tile
icon={<MemoryStick className="size-3.5" />}
label={t("proxmoxStats.memoryUsage")}
percent={node?.memory.percent ?? null}
detail={
node?.memory.usedGiB != null && node?.memory.totalGiB != null
? `${node.memory.usedGiB.toFixed(1)} / ${node.memory.totalGiB.toFixed(1)} GiB`
: undefined
}
sparkline={histories.memory}
/>
<Tile
icon={<HardDrive className="size-3.5" />}
label={t("proxmoxStats.diskUsage")}
percent={node?.disk.percent ?? null}
detail={
node?.disk.usedGiB != null && node?.disk.totalGiB != null
? `${node.disk.usedGiB.toFixed(1)} / ${node.disk.totalGiB.toFixed(1)} GiB`
: undefined
}
sparkline={histories.disk}
/>
<div className="flex min-w-0 flex-1 flex-col justify-between gap-2 border border-border bg-card px-4 py-3">
<div className="flex items-center gap-2 text-muted-foreground">
<Clock className="size-3.5" />
<span className="text-[10px] font-bold uppercase tracking-widest">
{t("proxmoxStats.uptime")}
</span>
</div>
<span className="text-2xl font-bold">
{node?.uptime.formatted ?? "N/A"}
</span>
<div className="flex items-center gap-1.5 truncate text-[11px] text-muted-foreground">
<Server className="size-3 shrink-0" />
<span className="truncate">
{node?.system.hostname ?? "-"}
{node?.system.pveVersion ? ` · ${node.system.pveVersion}` : ""}
</span>
</div>
</div>
</div>
);
}
@@ -0,0 +1,52 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { ProxmoxStatsTab } from "@/features/proxmox-stats/ProxmoxStatsTab.tsx";
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
interface ProxmoxStatsAppProps {
hostId?: string;
}
const ProxmoxStatsApp: React.FC<ProxmoxStatsAppProps> = ({ hostId }) => {
const { t } = useTranslation();
return (
<FullScreenAppWrapper hostId={hostId}>
{(hostConfig, phase) => {
if (phase === "loading") {
return (
<div className="relative h-full w-full">
<ConnectionScreen
status="connecting"
message={t("hosts.loadingHost")}
/>
</div>
);
}
if (!hostConfig) {
return (
<div className="relative h-full w-full">
<ConnectionScreen
status="disconnected"
message={t("hosts.hostNotFound")}
/>
</div>
);
}
return (
<ProxmoxStatsTab
hostConfig={hostConfig}
title={hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`}
isVisible={true}
isTopbarOpen={false}
embedded={true}
/>
);
}}
</FullScreenAppWrapper>
);
};
export default ProxmoxStatsApp;
@@ -0,0 +1,381 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React from "react";
import { Button } from "@/components/button.tsx";
import {
getProxmoxStats,
startProxmoxStatsPolling,
stopProxmoxStatsPolling,
sendProxmoxStatsHeartbeat,
getSSHHosts,
} from "@/main-axios.ts";
import type { ProxmoxStatsSnapshot } from "@/types/proxmox";
import type { ProxmoxStatsConfig } from "@/types";
import { useTranslation } from "react-i18next";
import { RefreshCw, Server } from "lucide-react";
import { NodeSummaryStrip } from "./NodeSummaryStrip";
import { GuestTable } from "./GuestTable";
import { NodeNetworkCard } from "./cards/NodeNetworkCard";
import { StoragePoolsCard } from "./cards/StoragePoolsCard";
import { ClusterHealthCard } from "./cards/ClusterHealthCard";
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
import {
ConnectionLogProvider,
useConnectionLog,
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
import { useConnectionRetry } from "@/lib/useConnectionRetry.ts";
import { runAdaptivePolling } from "@/lib/adaptive-polling.ts";
const HISTORY_LEN = 30;
function statsChangeKey(data: ProxmoxStatsSnapshot): string {
const bucket = (value: number | null | undefined) =>
value == null ? null : Math.round(value / 5) * 5;
return JSON.stringify({
cpu: bucket(data.node.cpu.percent),
memory: bucket(data.node.memory.percent),
disk: bucket(data.node.disk.percent),
guests: data.guests.counts,
cluster: data.cluster,
});
}
const DEFAULT_POLL_INTERVAL = 60;
interface HostConfig {
id: number;
name: string;
ip: string;
username: string;
enableProxmoxStats?: boolean;
proxmoxStatsConfig?: string | ProxmoxStatsConfig | null;
authType?: string;
port?: number;
[key: string]: unknown;
}
interface ProxmoxStatsProps {
hostConfig?: HostConfig;
title?: string;
isVisible?: boolean;
isTopbarOpen?: boolean;
embedded?: boolean;
}
interface ProxmoxStatsHistories {
cpu: number[];
memory: number[];
disk: number[];
}
function parseProxmoxStatsConfig(
raw?: string | ProxmoxStatsConfig | null,
): Required<Pick<ProxmoxStatsConfig, "pollInterval">> & ProxmoxStatsConfig {
const defaults = { pollInterval: DEFAULT_POLL_INTERVAL, nodeName: null };
if (!raw) return defaults;
try {
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
return { ...defaults, ...parsed };
} catch {
return defaults;
}
}
export function ProxmoxStatsTab(props: ProxmoxStatsProps): React.ReactElement {
return (
<ConnectionLogProvider>
<ProxmoxStatsInner {...props} />
</ConnectionLogProvider>
);
}
function ProxmoxStatsInner({
hostConfig,
title,
isVisible = true,
isTopbarOpen = true,
embedded = false,
}: ProxmoxStatsProps): React.ReactElement {
const { t } = useTranslation();
const { addLog, clearLogs } = useConnectionLog();
const [snapshot, setSnapshot] = React.useState<ProxmoxStatsSnapshot | null>(
null,
);
const [histories, setHistories] = React.useState<ProxmoxStatsHistories>({
cpu: [],
memory: [],
disk: [],
});
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
const [isRefreshing, setIsRefreshing] = React.useState(false);
const [isPageVisible, setIsPageVisible] = React.useState(!document.hidden);
const [viewerSessionId, setViewerSessionId] = React.useState<string | null>(
null,
);
const statsConfig = React.useMemo(
() => parseProxmoxStatsConfig(currentHostConfig?.proxmoxStatsConfig),
[currentHostConfig?.proxmoxStatsConfig],
);
React.useEffect(() => {
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 interval = setInterval(() => {
sendProxmoxStatsHeartbeat(viewerSessionId).catch(() => {});
}, 30000);
return () => clearInterval(interval);
}, [viewerSessionId, isActuallyVisible]);
React.useEffect(() => {
if (hostConfig?.id !== currentHostConfig?.id) {
setSnapshot(null);
setHistories({ cpu: [], memory: [], disk: [] });
}
setCurrentHostConfig(hostConfig);
}, [hostConfig?.id]);
React.useEffect(() => {
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 {
// keep previous config
}
};
fetchLatest();
const onChanged = () => fetchLatest();
window.addEventListener("ssh-hosts:changed", onChanged);
return () => window.removeEventListener("ssh-hosts:changed", onChanged);
}, [hostConfig?.id]);
const pushHistory = React.useCallback((data: ProxmoxStatsSnapshot) => {
setHistories((prev) => {
const add = (arr: number[], v: number | null | undefined) =>
[...arr, v ?? 0].slice(-HISTORY_LEN);
return {
cpu: add(prev.cpu, data.node?.cpu?.percent),
memory: add(prev.memory, data.node?.memory?.percent),
disk: add(prev.disk, data.node?.disk?.percent),
};
});
}, []);
const notEnabled = currentHostConfig?.enableProxmoxStats !== true;
const stopPollingRef = React.useRef<(() => void) | null>(null);
const fetchSnapshot = React.useCallback(async (): Promise<void> => {
if (!currentHostConfig?.id) return;
addLog({
type: "info",
stage: "stats_connecting",
message: t("proxmoxStats.connecting"),
});
const result = await startProxmoxStatsPolling(currentHostConfig.id);
if (result.viewerSessionId) setViewerSessionId(result.viewerSessionId);
addLog({
type: "info",
stage: "stats_polling",
message: t("proxmoxStats.connecting"),
});
const data = await getProxmoxStats(currentHostConfig.id);
if (!data) {
throw new Error(t("proxmoxStats.connectionFailed"));
}
setSnapshot(data);
addLog({
type: "success",
stage: "connected",
message: t("terminal.connected"),
});
const intervalMs =
(statsConfig.pollInterval ?? DEFAULT_POLL_INTERVAL) * 1000;
let signature = statsChangeKey(data);
stopPollingRef.current?.();
stopPollingRef.current = runAdaptivePolling(
async () => {
const next = await getProxmoxStats(currentHostConfig.id);
if (!next) throw new Error(t("proxmoxStats.connectionFailed"));
const nextSignature = statsChangeKey(next);
const changed = nextSignature !== signature;
signature = nextSignature;
setSnapshot(next);
pushHistory(next);
return changed;
},
{
minIntervalMs: intervalMs,
maxIntervalMs: Math.min(120_000, intervalMs * 6),
stablePollsPerStep: 3,
},
{ runImmediately: false },
);
}, [currentHostConfig?.id, statsConfig.pollInterval, addLog, t, pushHistory]);
const retry = useConnectionRetry({
connect: async () => {
try {
await fetchSnapshot();
retry.markConnected();
} catch (error: unknown) {
addLog({
type: "error",
stage: "error",
message:
error instanceof Error
? error.message
: t("proxmoxStats.connectionFailed"),
});
retry.markFailed();
}
},
enabled: isActuallyVisible && !notEnabled && !!currentHostConfig?.id,
autoStart: false,
});
const retryRef = React.useRef(retry);
retryRef.current = retry;
React.useEffect(() => {
if (notEnabled || !currentHostConfig?.id) return;
let cancelled = false;
const debounce = setTimeout(() => {
if (isActuallyVisible && !cancelled) {
clearLogs();
retryRef.current.reset();
retryRef.current.retryNow();
} else if (!isActuallyVisible) {
stopPollingRef.current?.();
stopPollingRef.current = null;
if (currentHostConfig?.id) {
stopProxmoxStatsPolling(currentHostConfig.id, undefined).catch(
() => {},
);
}
}
}, 500);
return () => {
cancelled = true;
clearTimeout(debounce);
stopPollingRef.current?.();
stopPollingRef.current = null;
if (currentHostConfig?.id) {
stopProxmoxStatsPolling(currentHostConfig.id).catch(() => {});
}
};
}, [currentHostConfig?.id, notEnabled, isActuallyVisible]);
const wrapperStyle: React.CSSProperties = embedded
? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" }
: {
opacity: isVisible ? 1 : 0,
margin: isTopbarOpen ? "74px 17px 8px 8px" : "16px 17px 8px 8px",
height: isTopbarOpen ? "calc(100vh - 82px)" : "calc(100vh - 24px)",
};
const handleRefresh = async () => {
if (!currentHostConfig?.id) return;
if (retry.status !== "connected") {
retry.retryNow();
return;
}
try {
setIsRefreshing(true);
const data = await getProxmoxStats(currentHostConfig.id);
if (data) {
setSnapshot(data);
pushHistory(data);
}
} finally {
setIsRefreshing(false);
}
};
const showContent = !notEnabled && retry.status === "connected" && snapshot;
return (
<div
style={wrapperStyle}
className="relative flex flex-col overflow-hidden"
>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{retry.status === "connected" && !notEnabled && (
<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="flex size-10 shrink-0 items-center justify-center border border-border bg-muted">
<Server className="size-5 text-accent-brand" />
</div>
<h1 className="text-lg font-bold md:text-2xl">{title}</h1>
</div>
<Button
variant="outline"
size="default"
onClick={handleRefresh}
disabled={isRefreshing}
className="gap-2 font-semibold"
>
<RefreshCw
className={`size-3.5 ${isRefreshing ? "animate-spin" : ""}`}
/>
{t("proxmoxStats.refresh")}
</Button>
</div>
)}
{showContent && (
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-3 pb-3 pt-3">
<div className="shrink-0">
<NodeSummaryStrip node={snapshot.node} histories={histories} />
</div>
<GuestTable guests={snapshot.guests.guests} />
<div className="grid shrink-0 grid-cols-1 gap-3 pb-1 md:grid-cols-3">
<NodeNetworkCard snapshot={snapshot} />
<StoragePoolsCard snapshot={snapshot} />
{snapshot.cluster.clustered && (
<ClusterHealthCard snapshot={snapshot} />
)}
</div>
</div>
)}
<ConnectionScreen
status={notEnabled ? "connected" : retry.status}
message={t("proxmoxStats.connecting")}
attempt={retry.attempt}
maxAttempts={retry.maxAttempts}
nextRetryInMs={retry.nextRetryInMs}
onManualRetry={retry.retryNow}
emptyState={
notEnabled ? (
<div className="text-center opacity-40">
<Server className="mx-auto mb-4 size-16" />
<p className="text-xl font-bold uppercase tracking-widest">
{t("proxmoxStats.noHostSelected")}
</p>
</div>
) : undefined
}
/>
</div>
</div>
);
}
@@ -0,0 +1,67 @@
import { Network } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ProxmoxStatsSnapshot } from "@/types/proxmox";
import { cn } from "@/lib/utils";
import { MetricCard } from "@/features/host-metrics/cards/MetricCard";
export function ClusterHealthCard({
snapshot,
}: {
snapshot: ProxmoxStatsSnapshot | null;
}) {
const { t } = useTranslation();
const cluster = snapshot?.cluster;
return (
<MetricCard
title={t("proxmoxStats.clusterHealth")}
icon={<Network className="size-3.5" />}
scroll
>
{!cluster || !cluster.clustered ? (
<span className="text-xs text-muted-foreground">
{t("proxmoxStats.notClustered")}
</span>
) : (
<div className="flex flex-col gap-2 text-xs">
<div className="flex items-center gap-2">
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
cluster.quorate ? "bg-green-500" : "bg-red-500",
)}
/>
<span className="font-medium">
{cluster.clusterName ?? t("proxmoxStats.clusterHealth")}
</span>
<span className="text-muted-foreground">
{cluster.quorate
? t("proxmoxStats.quorate")
: t("proxmoxStats.notQuorate")}
</span>
</div>
<div className="flex flex-col divide-y divide-border">
{cluster.nodes.map((node) => (
<div key={node.name} className="flex items-center gap-2 py-1.5">
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
node.online ? "bg-green-500" : "bg-muted-foreground/50",
)}
/>
<span className="min-w-0 flex-1 truncate font-medium">
{node.name}
</span>
<span className="shrink-0 text-[10px] text-muted-foreground">
{node.online
? t("proxmoxStats.nodeOnline")
: t("proxmoxStats.nodeOffline")}
</span>
</div>
))}
</div>
</div>
)}
</MetricCard>
);
}
@@ -0,0 +1,37 @@
import { Network } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ProxmoxStatsSnapshot } from "@/types/proxmox";
import { StatRow } from "@/components/charts";
import { MetricCard } from "@/features/host-metrics/cards/MetricCard";
export function NodeNetworkCard({
snapshot,
}: {
snapshot: ProxmoxStatsSnapshot | null;
}) {
const { t } = useTranslation();
const interfaces = snapshot?.network.interfaces ?? [];
return (
<MetricCard
title={t("proxmoxStats.networkInterfaces")}
icon={<Network className="size-3.5" />}
scroll
>
{interfaces.length === 0 ? (
<span className="text-xs text-muted-foreground">N/A</span>
) : (
<div className="flex flex-col divide-y divide-border">
{interfaces.map((iface) => (
<StatRow
key={iface.name}
label={iface.name}
value={iface.ip ?? iface.state ?? "-"}
mono
/>
))}
</div>
)}
</MetricCard>
);
}
@@ -0,0 +1,60 @@
import { Database } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ProxmoxStatsSnapshot } from "@/types/proxmox";
import { MetricCard } from "@/features/host-metrics/cards/MetricCard";
export function StoragePoolsCard({
snapshot,
}: {
snapshot: ProxmoxStatsSnapshot | null;
}) {
const { t } = useTranslation();
const pools = snapshot?.storage.pools ?? [];
return (
<MetricCard
title={t("proxmoxStats.storagePools")}
icon={<Database className="size-3.5" />}
scroll
>
{pools.length === 0 ? (
<span className="text-xs text-muted-foreground">N/A</span>
) : (
<div className="flex flex-col divide-y divide-border">
{pools.map((pool) => (
<div key={pool.name} className="flex flex-col gap-1 py-2 text-xs">
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-medium">
{pool.name}
</span>
<span className="shrink-0 rounded-full border border-border px-1.5 py-0.5 text-[10px] uppercase text-muted-foreground">
{pool.type}
</span>
{!pool.active && (
<span className="shrink-0 text-[10px] text-muted-foreground">
{t("proxmoxStats.inactive")}
</span>
)}
</div>
<div className="flex items-center gap-2">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
<div
className="h-full bg-accent-brand"
style={{
width: `${Math.min(100, Math.max(0, pool.percent ?? 0))}%`,
}}
/>
</div>
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground">
{pool.usedGiB !== null && pool.totalGiB !== null
? `${pool.usedGiB.toFixed(1)} / ${pool.totalGiB.toFixed(1)} GiB`
: "N/A"}
</span>
</div>
</div>
))}
</div>
)}
</MetricCard>
);
}
@@ -1,3 +1,4 @@
import { getErrorMessage } from "../../lib/error-message.js";
import { useCallback, useEffect, useRef, useState } from "react";
import { Pause, Play } from "lucide-react";
import { Terminal } from "@xterm/xterm";
@@ -138,9 +139,7 @@ function AsciicastPlayer({ blob }: { blob: Blob }) {
terminal.resize(recording.width, recording.height);
renderAt(0);
})
.catch((reason) =>
setError(reason instanceof Error ? reason.message : String(reason)),
);
.catch((reason) => setError(getErrorMessage(reason, String(reason))));
return () => {
observer.disconnect();
@@ -1,3 +1,5 @@
import { Terminal } from "@xterm/xterm";
export type AsciicastEvent = [
time: number,
type: "i" | "o" | "r",
@@ -37,3 +39,40 @@ export function parseAsciicast(source: string): Asciicast {
events,
};
}
function writeAsync(terminal: Terminal, data: string): Promise<void> {
return new Promise((resolve) => terminal.write(data, resolve));
}
// Replays the recording into an offscreen terminal and reads back its
// rendered buffer, so cursor movement/overwrites resolve the same way
// they would on screen instead of leaving raw ANSI escapes in the text.
export async function asciicastToPlainText(
recording: Asciicast,
): Promise<string> {
const terminal = new Terminal({
cols: recording.width,
rows: recording.height,
allowProposedApi: true,
});
try {
for (const [, type, data] of recording.events) {
if (type === "o") await writeAsync(terminal, data);
if (type === "r") {
const [cols, rows] = data.split("x").map(Number);
if (cols > 0 && rows > 0) terminal.resize(cols, rows);
}
}
const buffer = terminal.buffer.active;
const lines: string[] = [];
for (let i = 0; i < buffer.length; i++) {
lines.push(buffer.getLine(i)?.translateToString(true) ?? "");
}
while (lines.length > 0 && lines.at(-1) === "") lines.pop();
return lines.join("\n");
} finally {
terminal.dispose();
}
}
+370 -92
View File
@@ -1,3 +1,4 @@
import { getErrorMessage } from "../../lib/error-message.js";
/* eslint-disable react-hooks/exhaustive-deps */
import {
useEffect,
@@ -12,7 +13,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 { copyToClipboard, readFromClipboard } from "@/lib/clipboard";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { SearchAddon } from "@xterm/addon-search";
@@ -42,6 +43,7 @@ import { TmuxSessionPicker } from "@/ssh/dialogs/TmuxSessionPicker.tsx";
import {
DEFAULT_TERMINAL_CONFIG,
TERMINAL_FONTS,
resolveTerminalFontFamily,
} from "@/lib/terminal-themes.ts";
import { ensureTerminalFontsLoaded } from "./terminal-global-styles.ts";
import { useTheme } from "@/components/theme-provider.tsx";
@@ -53,33 +55,51 @@ import {
} from "@/lib/terminal-syntax-highlighter.ts";
import { useCommandHistory } from "@/features/terminal/command-history/CommandHistoryContext.tsx";
import { getAndroidHardwareKeySequence } from "@/features/terminal/android-hardware-keyboard.ts";
import {
buildImageUploadFormData,
type TerminalImageUploadSource,
} from "@/features/terminal/terminal-image-upload.ts";
import { CommandAutocomplete } from "./command-history/CommandAutocomplete.tsx";
import { TerminalSearchBar } from "./search/TerminalSearchBar.tsx";
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import {
ConnectionLogProvider,
useConnectionLog,
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
import { ConnectionLog } from "@/ssh/connection-log/ConnectionLog.tsx";
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { Save } from "lucide-react";
import { authApi } from "@/main-axios.ts";
import { resolveTermixThemeColors } from "./terminal-theme.ts";
import { ShareSessionModal } from "@/features/session-sharing/ShareSessionModal.tsx";
import { TerminalToolbar } from "./TerminalToolbar.tsx";
import type { TerminalHandle, TerminalHostConfig } from "./terminal-types.ts";
import { type Host, type Snippet, type TabType } from "@/types/ui-types";
import {
getNextTerminalFontSize,
getTerminalFontZoomDirection,
} from "./terminal-font-zoom.ts";
import { isTabKeyEvent } from "./terminal-key-event.ts";
import { installTouchWheelCoordinator } from "./touch-wheel-coordinator.ts";
import { loadTouchInputSettings } from "./touch-input-settings-store.ts";
import { quoteTerminalImagePath } from "./terminal-image-path.ts";
import {
getUserPreferences,
parseCustomKeybindings,
} from "@/api/open-tabs-api";
import { findMatchingKeybinding } from "@/lib/keybinding-match";
import { dispatchKeybindingAction } from "@/lib/keybinding-dispatch";
import {
dispatchKeybindingAction,
sendRawToSocket,
} from "@/lib/keybinding-dispatch";
import { SnippetVariablesDialog } from "@/components/SnippetVariablesDialog";
import type { CustomKeybinding } from "@/types/keybindings";
import { useConnectionDefaults } from "@/contexts/ConnectionDefaultsContext";
import {
TerminalLocalEcho,
resolveLocalEchoMode,
} from "@/lib/terminal-local-echo";
export type { TerminalHandle, TerminalHostConfig } from "./terminal-types.ts";
type HostKeyVerificationData = Omit<
@@ -106,6 +126,11 @@ interface SSHTerminalProps {
disableAutoFocus?: boolean;
isQuickConnect?: boolean;
onSaveQuickConnect?: () => Promise<void>;
/** Full host record, used to drive the context-aware terminal toolbar. */
host?: Host;
onOpenTab?: (type: TabType) => void;
/** False when this terminal sits in an unfocused split pane. */
isFocusedPane?: boolean;
}
const ALTERNATE_SCREEN_SEQUENCE = /\x1b\[\?(47|1047|1049)([hl])/g;
@@ -141,6 +166,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
disableAutoFocus = false,
isQuickConnect = false,
onSaveQuickConnect,
host,
onOpenTab,
isFocusedPane = true,
},
ref,
) {
@@ -149,17 +177,21 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const commandHistoryContext = useCommandHistory();
const { confirmWithToast } = useConfirmation();
const { theme: appTheme } = useTheme();
const { addLog, isExpanded: isConnectionLogExpanded } = useConnectionLog();
const { addLog } = useConnectionLog();
const { terminal: terminalDefaults } = useConnectionDefaults();
const outputListenersRef = useRef(new Set<(data: string) => void>());
const savedTheme = localStorage.getItem(
`terminal_theme_host_${hostConfig.id}`,
);
const config = {
...DEFAULT_TERMINAL_CONFIG,
...terminalDefaults,
...hostConfig.terminalConfig,
theme:
savedTheme ||
hostConfig.terminalConfig?.theme ||
terminalDefaults.theme ||
DEFAULT_TERMINAL_CONFIG.theme,
};
@@ -176,10 +208,17 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
: themeColors.background;
const fitAddonRef = useRef<FitAddon | null>(null);
const webSocketRef = useRef<WebSocket | null>(null);
const terminalInputDisposableRef = useRef<{ dispose(): void } | null>(null);
const localEchoRef = useRef<TerminalLocalEcho | null>(null);
const customKeybindingsRef = useRef<CustomKeybinding[]>([]);
const cachedSnippetsRef = useRef<{ id: number; content: string }[] | null>(
null,
);
const [pendingKeybindingSnippet, setPendingKeybindingSnippet] = useState<{
id: string;
content: string;
appendEnter: boolean;
} | null>(null);
const resizeTimeout = useRef<NodeJS.Timeout | null>(null);
const wasDisconnectedBySSH = useRef(false);
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
@@ -189,6 +228,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const [shareModalOpen, setShareModalOpen] = useState(false);
const [isSavingQuickConnect, setIsSavingQuickConnect] = useState(false);
const [isQuickConnectSaved, setIsQuickConnectSaved] = useState(false);
const [isImageUploading, setIsImageUploading] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [isFitted, setIsFitted] = useState(false);
const [connectionError, setConnectionError] = useState<string | null>(null);
@@ -478,6 +518,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const activityLoggingRef = useRef(false);
const passwordPromptShownRef = useRef(false);
const passwordPromptBufferRef = useRef("");
const alternateScreenModeRef = useRef(false);
const controlStringModeRef = useRef(false);
@@ -874,9 +915,17 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}
function maybeOfferPasswordFill(strippedData: string) {
// PTY output can split a short prompt like "[sudo] password for user: "
// across multiple WebSocket chunks, so match against a rolling buffer
// of recent output rather than each chunk in isolation.
const buffered = (passwordPromptBufferRef.current + strippedData).slice(
-200,
);
passwordPromptBufferRef.current = buffered;
const passwordPromptPattern =
/(?:\[sudo\][^\n\r]*:\s*$|sudo:[^\n\r]*password[^\n\r]*required|password for [^\n\r]*:\s*$|Password:\s*$|password:\s*$)/im;
if (!passwordPromptPattern.test(strippedData)) return;
if (!passwordPromptPattern.test(buffered)) return;
const hasStoredPassword =
hostConfig.terminalConfig?.sudoPassword ||
@@ -886,7 +935,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
if (!hasStoredPassword || passwordPromptShownRef.current) return;
passwordPromptShownRef.current = true;
const isSudoPrompt = /(?:\[sudo\]|sudo:)/i.test(strippedData);
passwordPromptBufferRef.current = "";
const isSudoPrompt = /(?:\[sudo\]|sudo:)/i.test(buffered);
confirmWithToast(
t("terminal.passwordPromptFillTitle"),
@@ -1004,6 +1054,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
webSocketRef.current.send(JSON.stringify({ type: "input", data }));
}
},
subscribeOutput: (listener: (data: string) => void) => {
outputListenersRef.current.add(listener);
return () => outputListenersRef.current.delete(listener);
},
paste: (text: string) => {
terminal?.paste(text);
},
@@ -1178,6 +1232,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
webSocketRef.current &&
webSocketRef.current.readyState !== WebSocket.CLOSED
) {
terminalInputDisposableRef.current?.dispose();
terminalInputDisposableRef.current = null;
webSocketRef.current.close();
}
@@ -1289,7 +1345,15 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}),
);
}
terminal.onData((data) => {
terminalInputDisposableRef.current?.dispose();
localEchoRef.current = new TerminalLocalEcho(
resolveLocalEchoMode(
hostConfig.terminalConfig?.localEcho,
localStorage.getItem("terminalLocalEchoMode"),
),
);
terminalInputDisposableRef.current = terminal.onData((data) => {
if (ws.readyState !== WebSocket.OPEN) return;
if (data === "\r" || data === "\n") {
const currentCmd = getCurrentCommand().trim();
const termixMatch = currentCmd.match(/^termix\s+(.+)$/);
@@ -1309,6 +1373,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}
}
trackInput(data);
const predicted = localEchoRef.current?.handleInput(data);
if (predicted) terminal.write(predicted);
ws.send(JSON.stringify({ type: "input", data }));
});
@@ -1337,6 +1403,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}
if (msg.type === "data") {
if (typeof msg.data === "string") {
outputListenersRef.current.forEach((listener) =>
listener(msg.data),
);
if (showAutocompleteRef.current) {
showAutocompleteRef.current = false;
setShowAutocomplete(false);
@@ -1344,17 +1413,21 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
currentAutocompleteCommand.current = "";
}
terminal.write(formatTerminalOutput(msg.data));
const output =
localEchoRef.current?.handleOutput(msg.data) ?? msg.data;
terminal.write(formatTerminalOutput(output));
// Strip ANSI escape codes before testing — newer sudo versions (Ubuntu 26.04+)
// emit colored prompts with embedded escape sequences that break the regex.
const strippedData = msg.data.replace(
/\x1b(?:[@-Z\\-_]|\[[0-9;?>=!]*[@-~])/g,
/\x1b(?:[@-Z\\-_]|\[[0-9:;<=>?!]*[@-~])/g,
"",
);
maybeOfferPasswordFill(strippedData);
} else {
const stringData = String(msg.data);
terminal.write(formatTerminalOutput(stringData));
const output =
localEchoRef.current?.handleOutput(stringData) ?? stringData;
terminal.write(formatTerminalOutput(output));
}
} else if (msg.type === "error") {
const errorMessage = msg.message || t("terminal.unknownError");
@@ -1433,6 +1506,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
setTimeout(async () => {
const terminalConfig = {
...DEFAULT_TERMINAL_CONFIG,
...terminalDefaults,
...hostConfig.terminalConfig,
};
@@ -1988,6 +2062,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
return;
}
terminalInputDisposableRef.current?.dispose();
terminalInputDisposableRef.current = null;
setIsConnected(false);
isConnectingRef.current = false;
@@ -2120,20 +2197,11 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}
async function readTextFromClipboard(): Promise<string> {
try {
if (window.electronClipboard) {
return window.electronClipboard.readText();
}
if (navigator.clipboard && navigator.clipboard.readText) {
return await navigator.clipboard.readText();
}
} catch {
// fall through
}
if (window.location.protocol !== "https:" && !isElectron()) {
const text = await readFromClipboard();
if (!text && window.location.protocol !== "https:" && !isElectron()) {
toast.error(t("terminal.clipboardHttpWarning"));
}
return "";
return text;
}
const handleSelectCommand = useCallback(
@@ -2216,6 +2284,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const config = {
...DEFAULT_TERMINAL_CONFIG,
...terminalDefaults,
...hostConfig.terminalConfig,
};
@@ -2226,11 +2295,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
config.customThemeColors,
);
const fontConfig = TERMINAL_FONTS.find(
(f) => f.value === config.fontFamily,
);
const fontFamily = fontConfig?.fallback || TERMINAL_FONTS[0].fallback;
ensureTerminalFontsLoaded(fontConfig?.value || TERMINAL_FONTS[0].value);
const fontFamily = resolveTerminalFontFamily(config.fontFamily);
ensureTerminalFontsLoaded(config.fontFamily || TERMINAL_FONTS[0].value);
// Update terminal options individually to avoid re-initialization flashes
terminal.options.cursorBlink = config.cursorBlink;
@@ -2240,12 +2306,11 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
terminalFontSizeRef.current = config.fontSize;
terminal.options.fontFamily = fontFamily;
terminal.options.rightClickSelectsWord = config.rightClickSelectsWord;
terminal.options.macOptionIsMeta = config.macOptionIsMeta;
terminal.options.fastScrollSensitivity = config.fastScrollSensitivity;
terminal.options.minimumContrastRatio = config.minimumContrastRatio;
terminal.options.letterSpacing = config.letterSpacing;
terminal.options.lineHeight = config.lineHeight;
terminal.options.bellStyle = config.bellStyle as
"none" | "sound" | "visual" | "both";
terminal.options.theme = {
background: config.backgroundImage
@@ -2281,21 +2346,26 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
// Refresh terminal to apply new theme colors to existing buffer content
hardRefresh();
}, [terminal, hostConfig.terminalConfig, previewTheme, appTheme, isFitted]);
}, [
terminal,
terminalDefaults,
hostConfig.terminalConfig,
previewTheme,
appTheme,
isFitted,
]);
useEffect(() => {
if (!terminal || !xtermRef.current) return;
const config = {
...DEFAULT_TERMINAL_CONFIG,
...terminalDefaults,
...hostConfig.terminalConfig,
};
const fontConfig = TERMINAL_FONTS.find(
(f) => f.value === config.fontFamily,
);
const fontFamily = fontConfig?.fallback || TERMINAL_FONTS[0].fallback;
ensureTerminalFontsLoaded(fontConfig?.value || TERMINAL_FONTS[0].value);
const fontFamily = resolveTerminalFontFamily(config.fontFamily);
ensureTerminalFontsLoaded(config.fontFamily || TERMINAL_FONTS[0].value);
const activeTheme = previewTheme || config.theme;
const themeColors = resolveTermixThemeColors(
@@ -2313,7 +2383,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
fontFamily,
allowTransparency: true, // MUST be set before open()
convertEol: false,
macOptionIsMeta: true,
macOptionIsMeta: config.macOptionIsMeta,
macOptionClickForcesSelection: false,
rightClickSelectsWord: config.rightClickSelectsWord,
fastScrollSensitivity: config.fastScrollSensitivity,
@@ -2321,7 +2391,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
minimumContrastRatio: config.minimumContrastRatio,
letterSpacing: config.letterSpacing,
lineHeight: config.lineHeight,
bellStyle: config.bellStyle as "none" | "sound" | "visual" | "both",
theme: {
background: config.backgroundImage
? "transparent"
@@ -2417,6 +2486,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const cfg = {
...DEFAULT_TERMINAL_CONFIG,
...terminalDefaults,
...hostConfig.terminalConfig,
};
const mod = cfg.fastScrollModifier;
@@ -2444,6 +2514,20 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
});
});
// Send one-finger drags through xterm's real wheel DOM path. This keeps
// scrollback, alternate-buffer/tmux, mouse reporting and wheel remainder
// handling identical to desktop wheel input.
let disposeTouchWheel = () => {};
let touchWheelDisposed = false;
loadTouchInputSettings().then((settings) => {
if (!touchWheelDisposed && xtermRef.current) {
disposeTouchWheel = installTouchWheelCoordinator(
xtermRef.current,
undefined,
settings,
);
}
});
const element = xtermRef.current;
const handleContextMenu = (e: MouseEvent) => {
if (e.ctrlKey && onOpenFileManager) {
@@ -2505,6 +2589,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const config = {
...DEFAULT_TERMINAL_CONFIG,
...terminalDefaults,
...hostConfig.terminalConfig,
};
if (config.backspaceMode !== "control-h") return;
@@ -2548,6 +2633,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
resizeObserver.observe(observeTarget);
return () => {
touchWheelDisposed = true;
isFittingRef.current = false;
resizeObserver.disconnect();
clipboardProvider.dispose();
@@ -2558,6 +2644,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
element?.removeEventListener("mouseup", handleTmuxDragEnd);
element?.removeEventListener("keydown", handleBackspaceMode, true);
element?.removeEventListener("keydown", handleTabCapture, true);
disposeTouchWheel();
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
};
@@ -2565,6 +2652,14 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const isMountedRef = useRef(false);
useEffect(
() => () => {
terminalInputDisposableRef.current?.dispose();
terminalInputDisposableRef.current = null;
},
[],
);
useEffect(() => {
isMountedRef.current = true;
@@ -2670,6 +2765,17 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
writeTextToClipboard,
readTextFromClipboard,
getSnippetById,
hostContext: {
ip: hostConfig.ip,
username: hostConfig.username,
port: hostConfig.port,
name: hostConfig.name,
},
onSnippetNeedsInputs: (snippet) =>
setPendingKeybindingSnippet({
...snippet,
appendEnter: matched.action.appendEnter !== false,
}),
});
return false;
}
@@ -2743,7 +2849,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
"ArrowUp",
"ArrowDown",
];
if (arrowCodes.includes(e.code)) {
if (arrowCodes.includes(e.code) || /^Digit[1-9]$/.test(e.code)) {
e.stopPropagation();
globalShortcutHandler.current?.(e);
return false;
@@ -3119,6 +3225,131 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const hasConnectionError = !!connectionError;
function getImageUploadErrorMessage(error: unknown): string {
if (error instanceof Error && error.message) return error.message;
const response = (
error as {
response?: {
data?: { error?: string; message?: string };
};
}
)?.response;
return (
response?.data?.error ||
response?.data?.message ||
"Image upload failed"
);
}
async function handleImageUpload(
file: File,
source: TerminalImageUploadSource,
) {
if (file.type && !file.type.startsWith("image/")) {
toast.error("Choose an image file");
return;
}
setIsImageUploading(true);
try {
const form = buildImageUploadFormData(
file,
hostConfig.instanceId ?? "",
source,
);
const response = await authApi.post("/terminal/image-upload", form, {
headers: { "Content-Type": undefined },
});
const { shellPath } = response.data as {
shellPath: string;
};
const pathInserted =
webSocketRef.current?.readyState === WebSocket.OPEN;
if (pathInserted) {
webSocketRef.current?.send(
JSON.stringify({
type: "input",
data: quoteTerminalImagePath(shellPath),
}),
);
toast.success(`Image uploaded: ${shellPath}`);
} else {
toast.warning(
`Image uploaded, but the terminal was not available to paste it: ${shellPath}`,
);
}
} catch (error) {
const response = (error as { response?: { data?: { code?: string } } })
?.response;
const code = response?.data?.code;
const message = getImageUploadErrorMessage(error);
toast.error(code ? `${message} (${code})` : message);
} finally {
setIsImageUploading(false);
}
}
async function handleClipboardImage() {
if (!navigator.clipboard?.read) {
toast.error("Clipboard image access is not available in this browser");
return;
}
setIsImageUploading(true);
try {
const items = await navigator.clipboard.read();
for (const item of items) {
const imageType = item.types.find((type) =>
type.startsWith("image/"),
);
if (!imageType) continue;
const blob = await item.getType(imageType);
let clipboardFile = new File([blob], "clipboard-image.png", {
type: imageType,
});
// Preserve native PNG clipboard bytes. Some browser/platform
// clipboard implementations decode transparent PNGs incorrectly
// through canvas, producing an all-black/transparent re-encode.
// Only rasterize formats that need conversion; Sharp validates the
// resulting image server-side.
if (
imageType !== "image/png" &&
typeof createImageBitmap === "function"
) {
try {
const bitmap = await createImageBitmap(blob);
try {
const canvas = document.createElement("canvas");
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const context = canvas.getContext("2d");
if (!context) throw new Error("Canvas unavailable");
context.drawImage(bitmap, 0, 0);
const png = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob((result) => {
if (result) resolve(result);
else reject(new Error("Clipboard image conversion failed"));
}, "image/png");
});
clipboardFile = new File([png], "clipboard-image.png", {
type: "image/png",
});
} finally {
bitmap.close();
}
} catch {
// Fall back to the original clipboard blob.
}
}
await handleImageUpload(clipboardFile, "clipboard");
return;
}
toast.error("No image found in the clipboard");
} catch (error) {
toast.error(getErrorMessage(error, "Clipboard read failed"));
} finally {
setIsImageUploading(false);
}
}
return (
<div
className="h-full w-full relative"
@@ -3158,20 +3389,31 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}}
/>
{isTmuxAttached && isConnected && (
<button
onClick={() => {
{host && host.enableTerminalToolbar !== false && (
<TerminalToolbar
host={host}
isConnected={isConnected}
isTmuxAttached={isTmuxAttached}
onTmuxDetach={() => {
if (webSocketRef.current?.readyState === WebSocket.OPEN) {
webSocketRef.current.send(
JSON.stringify({ type: "tmux_detach" }),
);
}
}}
title={t("terminal.tmuxDetach")}
className="absolute top-2 right-2 z-[110] px-2 py-1 text-xs rounded bg-black/60 text-white/70 hover:text-white hover:bg-black/80 transition-colors"
>
tmux:detach
</button>
isImageUploading={isImageUploading}
onUploadImage={(file) => void handleImageUpload(file, "file")}
onPasteImage={() => void handleClipboardImage()}
onOpenTab={onOpenTab}
onOpenFiles={() => {
if (webSocketRef.current?.readyState === WebSocket.OPEN) {
webSocketRef.current.send(JSON.stringify({ type: "get_cwd" }));
} else {
onOpenFileManager?.("/");
}
}}
isFocused={isFocusedPane}
/>
)}
{isQuickConnect &&
@@ -3200,54 +3442,45 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
</Button>
)}
<SimpleLoader
visible={isConnecting && !isConnectionLogExpanded}
<ConnectionScreen
status={
showDisconnectedOverlay
? "disconnected"
: isConnecting
? "connecting"
: hasConnectionError
? "error"
: "connected"
}
message={t("terminal.connecting")}
backgroundColor={backgroundColor}
/>
{showDisconnectedOverlay && !isConnecting && (
<div
className="absolute inset-0 flex flex-col items-center justify-center gap-3 z-[120]"
style={{ backgroundColor }}
>
<p className="text-sm text-muted-foreground">
{t("terminal.connectionLost")}
</p>
<div className="flex gap-2">
<Button
onClick={() => {
setShowDisconnectedOverlay(false);
isUnmountingRef.current = false;
shouldNotReconnectRef.current = false;
isReconnectingRef.current = false;
isConnectingRef.current = false;
reconnectAttempts.current = 0;
wasDisconnectedBySSH.current = false;
wasConnectedRef.current = false;
updateConnectionError(null);
if (terminal) {
terminal.clear();
connectToHost(terminal.cols, terminal.rows);
}
}}
>
{t("terminal.reconnect")}
attempt={reconnectAttempts.current}
maxAttempts={maxReconnectAttempts}
disconnectedMessage={t("terminal.connectionLost")}
retryLabel={t("terminal.reconnect")}
onManualRetry={() => {
setShowDisconnectedOverlay(false);
isUnmountingRef.current = false;
shouldNotReconnectRef.current = false;
isReconnectingRef.current = false;
isConnectingRef.current = false;
reconnectAttempts.current = 0;
wasDisconnectedBySSH.current = false;
wasConnectedRef.current = false;
updateConnectionError(null);
if (terminal) {
terminal.clear();
connectToHost(terminal.cols, terminal.rows);
}
}}
extraActions={
onClose && (
<Button variant="outline" onClick={onClose}>
{t("terminal.closeTab")}
</Button>
{onClose && (
<Button variant="outline" onClick={onClose}>
{t("terminal.closeTab")}
</Button>
)}
</div>
</div>
)}
<ConnectionLog
isConnecting={isConnecting}
isConnected={isConnected}
hasConnectionError={hasConnectionError && !showDisconnectedOverlay}
position={hasConnectionError ? "top" : "bottom"}
)
}
logPosition={hasConnectionError ? "top" : "bottom"}
/>
<TOTPDialog
@@ -3475,6 +3708,51 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
/>
)}
{pendingKeybindingSnippet && (
<SnippetVariablesDialog
snippet={
{
id: 0,
name: t("newUi.sidebar.keybindings.actionRunSnippet"),
content: pendingKeybindingSnippet.content,
folder: null,
order: 0,
} as Snippet
}
host={{
ip: hostConfig.ip,
username: hostConfig.username,
port: hostConfig.port,
name: hostConfig.name,
}}
onCancel={() => setPendingKeybindingSnippet(null)}
onConfirm={(resolvedContent) => {
const appendEnter = pendingKeybindingSnippet.appendEnter;
setPendingKeybindingSnippet(null);
const send = () =>
sendRawToSocket(
webSocketRef,
resolvedContent + (appendEnter ? "\r" : ""),
);
const shouldConfirm =
localStorage.getItem("confirmSnippetExecution") === "true";
if (shouldConfirm) {
confirmWithToast(
t("newUi.sidebar.snippets.confirmRunMessage", {
name: t("newUi.sidebar.keybindings.actionRunSnippet"),
}),
send,
t("newUi.sidebar.snippets.confirmRunButton"),
t("newUi.sidebar.snippets.cancel"),
{ confirmOnEnter: true, duration: 6000 },
);
} else {
send();
}
}}
/>
)}
{tmuxSessionPicker && (
<TmuxSessionPicker
isOpen={true}
+13 -13
View File
@@ -2,6 +2,7 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { Terminal } from "@/features/terminal/Terminal.tsx";
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
interface TerminalAppProps {
hostId?: string;
@@ -19,26 +20,25 @@ const TerminalApp: React.FC<TerminalAppProps> = ({ hostId, tmuxSession }) => {
const { t } = useTranslation();
return (
<FullScreenAppWrapper hostId={hostId}>
{(hostConfig, loading) => {
if (loading) {
{(hostConfig, phase) => {
if (phase === "loading") {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
<p className="text-muted-foreground">
{t("hosts.loadingHost")}
</p>
</div>
<div className="relative h-full w-full">
<ConnectionScreen
status="connecting"
message={t("hosts.loadingHost")}
/>
</div>
);
}
if (!hostConfig) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-red-500 mb-4">{t("hosts.hostNotFound")}</p>
</div>
<div className="relative h-full w-full">
<ConnectionScreen
status="disconnected"
message={t("hosts.hostNotFound")}
/>
</div>
);
}
+5 -4
View File
@@ -1,5 +1,8 @@
import { useTheme } from "@/components/theme-provider";
import { TERMINAL_THEMES, TERMINAL_FONTS } from "@/lib/terminal-themes";
import {
TERMINAL_THEMES,
resolveTerminalFontFamily,
} from "@/lib/terminal-themes";
import type { TerminalConfig } from "@/types";
interface TerminalPreviewProps {
@@ -38,9 +41,7 @@ export function TerminalPreview({
theme === "custom" && customThemeColors
? customThemeColors
: TERMINAL_THEMES[resolvedTheme]?.colors;
const fontFallback =
TERMINAL_FONTS.find((f) => f.value === fontFamily)?.fallback ||
TERMINAL_FONTS[0].fallback;
const fontFallback = resolveTerminalFontFamily(fontFamily);
return (
<div className="border border-input overflow-hidden">
@@ -0,0 +1,851 @@
import React, {
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import {
ChevronDown,
ClipboardPaste,
GripVertical,
ImagePlus,
LayoutGrid,
LogOut,
Maximize2,
Minimize2,
X,
} from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/select";
import { cn } from "@/lib/utils";
import { useIsMobile } from "@/hooks/use-mobile";
import { getSshActions } from "@/sidebar/tree/HostItem/HostItem";
import {
getServerMetricsById,
startMetricsPolling,
stopMetricsPolling,
} from "@/api/host-metrics-status-api";
import type { Host, TabType } from "@/types/ui-types";
import {
getPollingEnvironmentMultiplier,
runAdaptivePolling,
} from "@/lib/adaptive-polling";
import {
clampToolbarPosition,
getResponsiveToolbarDensity,
persistToolbarPosition,
readStoredToolbarPosition,
type ToolbarPosition,
type ToolbarDensity,
} from "./toolbar-geometry";
type SelectedToolbarDensity = ToolbarDensity;
const DENSITY_STORAGE_KEY = "termix-terminal-toolbar-density";
const DENSITY_OPTIONS: { value: ToolbarDensity }[] = [
{ value: "icon" },
{ value: "labeled" },
{ value: "expanded" },
];
const DENSITIES = DENSITY_OPTIONS.map((option) => option.value);
function readStoredDensity(): ToolbarDensity {
if (typeof window === "undefined") return "labeled";
try {
const stored = window.localStorage.getItem(DENSITY_STORAGE_KEY);
return DENSITIES.includes(stored as ToolbarDensity)
? (stored as ToolbarDensity)
: "labeled";
} catch {
return "labeled";
}
}
const CONTROL =
"inline-flex min-h-8 min-w-8 items-center justify-center gap-1.5 rounded-sm px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground active:translate-y-px active:bg-muted/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-40 data-[state=open]:bg-muted data-[state=open]:text-foreground";
const SEPARATOR = "mx-0.5 h-5 w-px shrink-0 bg-border";
function StatBar({
label,
percent,
}: {
label: string;
percent: number | null;
}) {
const value = percent == null ? null : Math.max(0, Math.min(100, percent));
return (
<div className="flex items-center gap-1.5 px-1.5">
<span className="w-12 shrink-0 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{label}
</span>
<div className="h-1.5 w-12 shrink-0 overflow-hidden rounded-full bg-muted">
{value != null && (
<div
className={cn(
"h-full rounded-full transition-all",
value >= 90
? "bg-red-500"
: value >= 70
? "bg-yellow-500"
: "bg-accent-brand",
)}
style={{ width: `${value}%` }}
/>
)}
</div>
<span className="w-7 shrink-0 text-right text-[10px] tabular-nums text-muted-foreground">
{value == null ? "--" : `${Math.round(value)}%`}
</span>
</div>
);
}
interface TerminalToolbarProps {
host: Host;
terminalIdentity?: string;
isConnected: boolean;
isTmuxAttached: boolean;
onTmuxDetach: () => void;
isImageUploading: boolean;
onUploadImage: (file: File) => void | Promise<void>;
onPasteImage: () => void | Promise<void>;
onOpenTab?: (type: TabType) => void;
onOpenFiles?: () => void;
isFocused: boolean;
}
export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
host,
terminalIdentity,
isConnected,
isTmuxAttached,
onTmuxDetach,
isImageUploading,
onUploadImage,
onPasteImage,
onOpenTab,
onOpenFiles,
isFocused,
}) => {
const { t } = useTranslation();
const [density, setDensity] =
useState<SelectedToolbarDensity>(readStoredDensity);
const [responsiveDensity, setResponsiveDensity] =
useState<SelectedToolbarDensity>(readStoredDensity);
const [position, setPosition] = useState<ToolbarPosition>(
readStoredToolbarPosition,
);
const positionRef = useRef(position);
const [collapsed, setCollapsed] = useState(false);
const [densityOpen, setDensityOpen] = useState(false);
const [metrics, setMetrics] = useState<{
cpu: number | null;
memory: number | null;
disk: number | null;
} | null>(null);
const [statsAvailable, setStatsAvailable] = useState(true);
const [imageError, setImageError] = useState<string | null>(null);
const [retryImageAction, setRetryImageAction] = useState<(() => void) | null>(
null,
);
const fileInputRef = useRef<HTMLInputElement>(null);
const hideToolbarRef = useRef<HTMLButtonElement>(null);
const pendingExpansionFocusRef = useRef(false);
const pendingRightEdgeRef = useRef<number | null>(null);
const mountedRef = useRef(false);
const imageGenerationRef = useRef(0);
const hostRef = useRef<HTMLDivElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
const measurementRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<{
pointerId: number;
startX: number;
startY: number;
basePosition: ToolbarPosition;
} | null>(null);
const capturedPointerIdRef = useRef<number | null>(null);
const dragMovedRef = useRef(false);
const densityId = useId();
const [desktopViewportReady, setDesktopViewportReady] = useState<boolean>();
const hostIdentity = terminalIdentity ?? `${host.id ?? "unknown"}`;
positionRef.current = position;
const hostActions = getSshActions(host).filter(
(action) => action.type !== "terminal",
);
const effectiveDensity: ToolbarDensity = isFocused
? responsiveDensity
: "icon";
const showStats = effectiveDensity === "expanded";
const hostId = host.id ? Number(host.id) : null;
const isMobile = useIsMobile();
useLayoutEffect(() => {
setDesktopViewportReady(
typeof window !== "undefined" && window.innerWidth >= 768,
);
}, []);
useEffect(() => {
if (isMobile === false) setDesktopViewportReady(true);
}, [isMobile]);
useEffect(() => {
if (desktopViewportReady !== true || isMobile !== false) return;
try {
window.localStorage.setItem(DENSITY_STORAGE_KEY, density);
} catch {
// Storage can be unavailable in hardened browser contexts.
}
}, [density, desktopViewportReady, isMobile]);
// Picking an interface preset seeds this key, so pick the new value up
// without needing the tab to remount.
useEffect(() => {
if (desktopViewportReady !== true || isMobile !== false) return;
const handler = () => setDensity(readStoredDensity());
window.addEventListener("terminalToolbarDensityChanged", handler);
return () =>
window.removeEventListener("terminalToolbarDensityChanged", handler);
}, [desktopViewportReady, isMobile]);
useEffect(() => {
if (desktopViewportReady !== true || isMobile !== false) return;
const measurement = measurementRef.current;
const host = hostRef.current;
if (!measurement || !host) return;
let frameId: number | null = null;
let remeasurements = 0;
const measure = () => {
frameId = null;
const available = host.getBoundingClientRect().width;
const required = measurement.getBoundingClientRect().width;
if (available > 0 && required > 0) {
setResponsiveDensity((current) =>
getResponsiveToolbarDensity(density, current, available, required),
);
remeasurements = 0;
} else if (remeasurements < 2) {
remeasurements += 1;
frameId = window.requestAnimationFrame(measure);
}
};
const schedule = () => {
if (frameId == null) frameId = window.requestAnimationFrame(measure);
};
schedule();
const observer =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(schedule);
observer?.observe(host);
observer?.observe(measurement);
return () => {
if (frameId != null) window.cancelAnimationFrame(frameId);
observer?.disconnect();
};
}, [
density,
desktopViewportReady,
hostActions.length,
isConnected,
isMobile,
isTmuxAttached,
t,
]);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
imageGenerationRef.current += 1;
dragRef.current = null;
capturedPointerIdRef.current = null;
};
}, []);
useLayoutEffect(() => {
if (desktopViewportReady !== true || isMobile !== false) return;
const toolbar = toolbarRef.current;
const host = hostRef.current;
if (!toolbar || !host || dragRef.current) return;
const hostRect = host.getBoundingClientRect();
const toolbarRect = toolbar.getBoundingClientRect();
const extremeOffset =
hostRect.width > 0 &&
hostRect.height > 0 &&
Math.max(
Math.abs(positionRef.current.x),
Math.abs(positionRef.current.y),
) >
Math.max(hostRect.width, hostRect.height) * 4;
if (extremeOffset) {
positionRef.current = { x: 0, y: 0 };
setPosition({ x: 0, y: 0 });
persistToolbarPosition({ x: 0, y: 0 });
return;
}
if (toolbarRect.width > 0 && toolbarRect.height > 0) {
const pendingRightEdge = pendingRightEdgeRef.current;
pendingRightEdgeRef.current = null;
const baseRight = toolbarRect.right - positionRef.current.x;
const next = clampToolbarPosition(
{
x:
pendingRightEdge == null
? positionRef.current.x
: pendingRightEdge - baseRight,
y: positionRef.current.y,
},
toolbarRect,
hostRect,
positionRef.current,
);
if (
next.x !== positionRef.current.x ||
next.y !== positionRef.current.y
) {
positionRef.current = next;
setPosition(next);
persistToolbarPosition(next);
}
}
if (!collapsed && pendingExpansionFocusRef.current) {
pendingExpansionFocusRef.current = false;
hideToolbarRef.current?.focus();
}
}, [
collapsed,
density,
desktopViewportReady,
effectiveDensity,
hostActions.length,
isConnected,
isMobile,
isTmuxAttached,
responsiveDensity,
]);
useEffect(() => {
imageGenerationRef.current += 1;
setImageError(null);
setRetryImageAction(null);
}, [isConnected, hostIdentity]);
useEffect(() => {
if (collapsed || !pendingExpansionFocusRef.current) return;
pendingExpansionFocusRef.current = false;
hideToolbarRef.current?.focus();
}, [collapsed]);
useEffect(() => {
if (
!showStats ||
desktopViewportReady !== true ||
isMobile !== false ||
!hostId ||
collapsed ||
!isConnected
) {
setMetrics(null);
return;
}
let cancelled = false;
let stopPolling: (() => void) | undefined;
let viewerSessionId: string | undefined;
let previousMetrics: typeof metrics = null;
setStatsAvailable(true);
const poll = async () => {
try {
const data = await getServerMetricsById(hostId);
if (cancelled || !data) return;
const next = {
cpu: data.cpu?.percent ?? null,
memory: data.memory?.percent ?? null,
disk: data.disk?.percent ?? null,
};
const changed =
!previousMetrics ||
(["cpu", "memory", "disk"] as const).some((key) => {
const previous = previousMetrics?.[key];
const current = next[key];
return (
previous == null ||
current == null ||
Math.abs(current - previous) >= 2
);
});
previousMetrics = next;
setMetrics(next);
return changed;
} catch {
// Retain the previous sample after a transient polling error.
return false;
}
};
const start = async () => {
try {
const result = await startMetricsPolling(hostId);
if (cancelled) {
if (result.viewerSessionId) {
void stopMetricsPolling(hostId, result.viewerSessionId).catch(
() => {},
);
}
return;
}
if (result.requires_totp) {
setStatsAvailable(false);
return;
}
viewerSessionId = result.viewerSessionId;
stopPolling = runAdaptivePolling(
poll,
{
minIntervalMs: 5_000,
maxIntervalMs: 20_000,
stablePollsPerStep: 2,
maxRequestDutyCycle: 0.2,
},
{ intervalMultiplier: getPollingEnvironmentMultiplier },
);
} catch {
if (!cancelled) setStatsAvailable(false);
}
};
void start();
return () => {
cancelled = true;
stopPolling?.();
void stopMetricsPolling(hostId, viewerSessionId).catch(() => {});
};
}, [
desktopViewportReady,
showStats,
isMobile,
hostId,
collapsed,
isConnected,
hostIdentity,
]);
if (!isConnected || desktopViewportReady !== true || isMobile !== false)
return null;
const setAndStoreDensity = (next: ToolbarDensity) => {
setDensity(next);
};
const reportImageResult = (
action: () => void | Promise<void>,
retry: () => void,
) => {
const generation = ++imageGenerationRef.current;
let result: void | Promise<void>;
try {
result = action();
} catch (error) {
result = Promise.reject(error);
}
void Promise.resolve(result).then(
() => {
if (!mountedRef.current || generation !== imageGenerationRef.current)
return;
setImageError(null);
setRetryImageAction(null);
},
(error: unknown) => {
if (!mountedRef.current || generation !== imageGenerationRef.current)
return;
setImageError(
error instanceof Error
? error.message
: t("terminalToolbar.imageActionFailed"),
);
setRetryImageAction(() => retry);
},
);
};
const uploadFile = (file: File) => {
setImageError(null);
reportImageResult(
() => onUploadImage(file),
() => uploadFile(file),
);
};
const pasteImage = () => {
setImageError(null);
// Invoke clipboard access in this click's user-activation call chain.
reportImageResult(onPasteImage, pasteImage);
};
const chooseFile = () => fileInputRef.current?.click();
const handleGrabPointerDown = (
event: React.PointerEvent<HTMLButtonElement>,
) => {
if (event.button !== 0 || event.pointerType === "touch") return;
event.currentTarget.setPointerCapture?.(event.pointerId);
capturedPointerIdRef.current = event.pointerId;
dragMovedRef.current = false;
dragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
basePosition: positionRef.current,
};
};
const handleGrabPointerMove = (
event: React.PointerEvent<HTMLButtonElement>,
) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
const deltaX = event.clientX - drag.startX;
const deltaY = event.clientY - drag.startY;
const toolbar = toolbarRef.current;
const host = hostRef.current;
if (!toolbar || !host) return;
const next = clampToolbarPosition(
{
x: drag.basePosition.x + deltaX,
y: drag.basePosition.y + deltaY,
},
toolbar.getBoundingClientRect(),
host.getBoundingClientRect(),
positionRef.current,
);
if (event.clientX !== drag.startX || event.clientY !== drag.startY) {
dragMovedRef.current = true;
}
positionRef.current = next;
setPosition(next);
};
const handleGrabPointerEnd = (
event: React.PointerEvent<HTMLButtonElement>,
) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
persistToolbarPosition(positionRef.current);
dragRef.current = null;
if (capturedPointerIdRef.current === event.pointerId) {
capturedPointerIdRef.current = null;
if (event.type !== "lostpointercapture") {
try {
event.currentTarget.releasePointerCapture?.(event.pointerId);
} catch {
// The browser may have already released capture for this pointer.
}
}
}
};
const densityLabels: Record<ToolbarDensity, string> = {
icon: t("terminalToolbar.layoutIcon"),
labeled: t("terminalToolbar.layoutLabeled"),
expanded: t("terminalToolbar.layoutExpanded"),
};
const densitySelect = (
<Select
value={density}
open={densityOpen}
onOpenChange={setDensityOpen}
onValueChange={(value) => setAndStoreDensity(value as ToolbarDensity)}
>
<SelectTrigger
id={densityId}
size="sm"
aria-label={t("terminalToolbar.layout")}
title={t("terminalToolbar.layout")}
className={cn(
CONTROL,
"h-8 border-0 bg-transparent px-2 shadow-none dark:bg-transparent dark:hover:bg-muted",
)}
>
<LayoutGrid className="size-4 shrink-0" />
</SelectTrigger>
<SelectContent>
{DENSITY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{densityLabels[option.value]}
</SelectItem>
))}
</SelectContent>
</Select>
);
const renderHostAction = (action: (typeof hostActions)[number]) => {
const Icon = action.icon;
const invoke = () =>
action.type === "files" && onOpenFiles
? onOpenFiles()
: onOpenTab?.(action.type);
return (
<button
key={action.type}
type="button"
className={CONTROL}
aria-label={action.label}
title={action.label}
onClick={invoke}
>
<Icon className="size-4 shrink-0" />
{effectiveDensity !== "icon" && action.label}
</button>
);
};
const imageButtons = (
<>
<button
type="button"
className={CONTROL}
aria-label={t("terminalToolbar.uploadImage")}
title={t("terminalToolbar.uploadImage")}
disabled={isImageUploading}
onClick={chooseFile}
>
<ImagePlus className="size-4 shrink-0" />
{effectiveDensity !== "icon" && t("terminalToolbar.upload")}
</button>
<button
type="button"
className={CONTROL}
aria-label={t("terminalToolbar.pasteImage")}
title={t("terminalToolbar.pasteImage")}
disabled={isImageUploading}
onClick={pasteImage}
>
<ClipboardPaste className="size-4 shrink-0" />
{effectiveDensity !== "icon" && t("terminalToolbar.paste")}
</button>
</>
);
const expandButton = (
<button
type="button"
className={CONTROL}
aria-label={t("terminalToolbar.showToolbar")}
title={t("terminalToolbar.showToolbar")}
onClick={() => {
pendingRightEdgeRef.current =
toolbarRef.current?.getBoundingClientRect().right ?? null;
pendingExpansionFocusRef.current = true;
setCollapsed(false);
}}
>
<Maximize2 className="size-4" />
<span className="sr-only">{t("terminalToolbar.showToolbar")}</span>
</button>
);
const grabButton = () => (
<button
type="button"
className={cn(CONTROL, "cursor-grab active:cursor-grabbing")}
aria-label={t("terminalToolbar.moveToolbar")}
title={t("terminalToolbar.moveToolbarHint")}
onPointerDown={handleGrabPointerDown}
onPointerMove={handleGrabPointerMove}
onPointerUp={handleGrabPointerEnd}
onPointerCancel={handleGrabPointerEnd}
onLostPointerCapture={handleGrabPointerEnd}
>
<GripVertical className="size-4" />
</button>
);
return (
<>
<div
ref={hostRef}
data-terminal-toolbar-host
className={cn(
"pointer-events-none @container absolute inset-0 z-[110] flex items-end justify-center pb-2",
)}
>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="sr-only"
tabIndex={-1}
disabled={isImageUploading}
aria-hidden="true"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) uploadFile(file);
}}
/>
<span role="status" aria-live="polite" className="sr-only">
{isImageUploading ? t("terminalToolbar.uploadingImage") : ""}
</span>
{imageError && (
<div
role="alert"
className="pointer-events-auto absolute bottom-full mb-2 flex min-h-11 max-w-sm items-center gap-2 rounded-sm border border-destructive bg-background p-2 text-xs text-destructive shadow-lg"
>
<span>{imageError}</span>
{retryImageAction && (
<button
type="button"
className={cn(CONTROL, "text-foreground")}
title={t("terminalToolbar.retry")}
disabled={isImageUploading}
onClick={retryImageAction}
>
{t("terminalToolbar.retry")}
</button>
)}
<button
type="button"
className={cn(CONTROL, "px-2")}
aria-label={t("terminalToolbar.dismissError")}
title={t("terminalToolbar.dismissError")}
onClick={() => {
setImageError(null);
setRetryImageAction(null);
}}
>
<X className="size-4" />
</button>
</div>
)}
<div
ref={measurementRef}
aria-hidden="true"
className="pointer-events-none invisible absolute left-0 top-0 flex whitespace-nowrap"
>
{hostActions.map((action) => (
<span key={action.type} className={CONTROL}>
<action.icon className="size-4" />
{density !== "icon" && action.label}
</span>
))}
{isTmuxAttached && (
<span className={CONTROL}>
<LogOut className="size-4" />
{density !== "icon" && t("terminalToolbar.detachTmux")}
</span>
)}
<span className={CONTROL}>
<ImagePlus className="size-4" />
{density !== "icon" && t("terminalToolbar.upload")}
</span>
<span className={CONTROL}>
<ClipboardPaste className="size-4" />
{density !== "icon" && t("terminalToolbar.paste")}
</span>
<span className={cn(CONTROL, "h-8 px-2")}>
<LayoutGrid className="size-4" />
<ChevronDown className="size-4" />
</span>
<span className={CONTROL}>
<Minimize2 className="size-4" />
</span>
<span className={CONTROL}>
<GripVertical className="size-4" />
</span>
</div>
<div
ref={toolbarRef}
data-terminal-toolbar-wide
className={cn(
"pointer-events-auto transition-opacity duration-300 hover:duration-0 focus-within:duration-0 hover:opacity-100 focus-within:opacity-100",
collapsed || densityOpen ? "opacity-100" : "opacity-30",
)}
style={{
transform: `translate(${position.x}px, ${position.y}px)`,
}}
>
{collapsed ? (
<div className="terminal-toolbar-collapsed-shell flex rounded-sm border border-border bg-background/90 p-0.5 shadow-lg backdrop-blur-sm">
{expandButton}
{grabButton()}
</div>
) : (
<div className="flex flex-col overflow-hidden rounded-sm border border-border bg-background/90 shadow-lg backdrop-blur-sm">
{showStats && (
<div className="flex flex-wrap items-center justify-center gap-x-1 border-b border-border px-1.5 py-0.5">
{statsAvailable ? (
<>
<StatBar
label={t("terminalToolbar.cpu")}
percent={metrics?.cpu ?? null}
/>
<StatBar
label={t("terminalToolbar.memory")}
percent={metrics?.memory ?? null}
/>
<StatBar
label={t("terminalToolbar.disk")}
percent={metrics?.disk ?? null}
/>
</>
) : (
<span
role="status"
className="px-2 py-1 text-xs text-muted-foreground"
>
{t("terminalToolbar.statsUnavailable")}
</span>
)}
</div>
)}
<div className={cn("flex gap-0.5 p-0.5", "items-center")}>
{hostActions.map(renderHostAction)}
{isTmuxAttached && (
<button
type="button"
className={CONTROL}
aria-label={t("terminalToolbar.detachTmuxDescription")}
title={t("terminalToolbar.detachTmuxDescription")}
onClick={onTmuxDetach}
>
<LogOut className="size-4" />
{effectiveDensity !== "icon" &&
t("terminalToolbar.detachTmux")}
</button>
)}
{(hostActions.length > 0 || isTmuxAttached) && (
<div className={SEPARATOR} />
)}
<div
role="group"
aria-label={t("terminalToolbar.image")}
className="flex items-center gap-0.5"
>
{imageButtons}
</div>
<div className={SEPARATOR} />
{densitySelect}
<button
ref={hideToolbarRef}
type="button"
className={CONTROL}
aria-label={t("terminalToolbar.hideToolbar")}
title={t("terminalToolbar.hideToolbar")}
onClick={() => {
pendingRightEdgeRef.current =
toolbarRef.current?.getBoundingClientRect().right ?? null;
setCollapsed(true);
}}
>
<Minimize2 className="size-4" />
<span className="sr-only">
{t("terminalToolbar.hideToolbar")}
</span>
</button>
<div className={SEPARATOR} />
{grabButton()}
</div>
</div>
)}
</div>
</div>
</>
);
};
@@ -4,7 +4,7 @@ import React, {
useContext,
useState,
useCallback,
ReactNode,
type ReactNode,
} from "react";
interface CommandHistoryContextType {
@@ -0,0 +1,10 @@
/**
* Return an absolute POSIX path as one safe shell argument for terminal input.
*
* The backend already constrains this to an absolute agent-visible path. This
* helper handles spaces and shell metacharacters at the final terminal-input
* boundary so configured paths cannot become shell syntax.
*/
export function quoteTerminalImagePath(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
@@ -0,0 +1,15 @@
export type TerminalImageUploadSource = "file" | "clipboard";
export function buildImageUploadFormData(
file: File,
instanceId: string,
source: TerminalImageUploadSource,
clientUploadTimestamp = new Date().toISOString(),
): FormData {
const form = new FormData();
form.append("image", file);
form.append("instanceId", instanceId);
form.append("source", source);
form.append("clientUploadTimestamp", clientUploadTimestamp);
return form;
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { TERMINAL_THEMES } from "@/lib/terminal-themes.ts";
import { TERMINAL_THEMES, type TerminalTheme } from "@/lib/terminal-themes.ts";
import type { TerminalConfig } from "@/types/index.ts";
// Background/foreground per UI theme for "Termix Default" - must match index.css
@@ -21,7 +21,7 @@ export function resolveTermixThemeColors(
activeTheme: string,
appTheme: string,
customThemeColors?: TerminalConfig["customThemeColors"],
) {
): TerminalTheme["colors"] {
if (activeTheme === "custom") {
if (customThemeColors) {
return customThemeColors;
+3 -1
View File
@@ -7,6 +7,7 @@ export interface TerminalHostConfig {
/** Set when this tab joins someone else's live shared SSH session instead of connecting/attaching. */
joinSharedSessionId?: string | null;
joinShareId?: string | null;
name?: string;
ip: string;
port: number;
username: string;
@@ -16,7 +17,7 @@ export interface TerminalHostConfig {
keyType?: string;
authType?: string;
credentialId?: number;
terminalConfig?: TerminalConfig;
terminalConfig?: Partial<TerminalConfig>;
[key: string]: unknown;
}
@@ -27,6 +28,7 @@ export interface TerminalHandle {
fit: () => void;
focus: () => void;
sendInput: (data: string) => void;
subscribeOutput: (listener: (data: string) => void) => () => void;
paste: (text: string) => void;
notifyResize: () => void;
refresh: () => void;
@@ -0,0 +1,112 @@
export interface ToolbarPosition {
x: number;
y: number;
}
export interface RectLike {
left: number;
right: number;
top: number;
bottom: number;
width: number;
height: number;
}
export type ToolbarDensity = "icon" | "labeled" | "expanded";
export const TOOLBAR_POSITION_STORAGE_KEY =
"termix-terminal-toolbar-position-v2";
const TOOLBAR_MARGIN = 8;
const RECOVERY_SIZE = 44;
export function getDefaultToolbarPosition(): ToolbarPosition {
return { x: 0, y: 0 };
}
export function sanitizeToolbarPosition(value: unknown): ToolbarPosition {
if (!value || typeof value !== "object") return getDefaultToolbarPosition();
const candidate = value as Partial<ToolbarPosition>;
return Number.isFinite(candidate.x) && Number.isFinite(candidate.y)
? { x: Number(candidate.x), y: Number(candidate.y) }
: getDefaultToolbarPosition();
}
export function readStoredToolbarPosition(): ToolbarPosition {
if (typeof window === "undefined") return getDefaultToolbarPosition();
try {
return sanitizeToolbarPosition(
JSON.parse(
window.localStorage.getItem(TOOLBAR_POSITION_STORAGE_KEY) ?? "null",
),
);
} catch {
return getDefaultToolbarPosition();
}
}
export function persistToolbarPosition(position: ToolbarPosition): void {
try {
window.localStorage.setItem(
TOOLBAR_POSITION_STORAGE_KEY,
JSON.stringify(sanitizeToolbarPosition(position)),
);
} catch {
// Storage may be unavailable in hardened browser contexts.
}
}
export function clampToolbarPosition(
position: ToolbarPosition,
toolbarRect: RectLike,
hostRect: RectLike,
renderedPosition: ToolbarPosition = position,
): ToolbarPosition {
if (
!toolbarRect.width ||
!toolbarRect.height ||
!hostRect.width ||
!hostRect.height
)
return sanitizeToolbarPosition(position);
const left = hostRect.left + TOOLBAR_MARGIN;
const right = hostRect.right - TOOLBAR_MARGIN;
const top = hostRect.top + TOOLBAR_MARGIN;
const bottom = hostRect.bottom - TOOLBAR_MARGIN;
const baseLeft = toolbarRect.left - renderedPosition.x;
const baseRight = toolbarRect.right - renderedPosition.x;
const baseTop = toolbarRect.top - renderedPosition.y;
const baseBottom = toolbarRect.bottom - renderedPosition.y;
const clamp = (min: number, max: number, value: number) =>
Math.min(max, Math.max(min, value));
const xMin =
toolbarRect.width <= right - left
? left - baseLeft
: left + RECOVERY_SIZE - baseRight;
const xMax = right - baseRight;
const yMin =
toolbarRect.height <= bottom - top
? top - baseTop
: top + RECOVERY_SIZE - baseBottom;
const yMax = bottom - baseBottom;
return { x: clamp(xMin, xMax, position.x), y: clamp(yMin, yMax, position.y) };
}
export function getResponsiveToolbarDensity(
selectedDensity: ToolbarDensity,
currentDensity: ToolbarDensity,
availableInlineSize: number,
requiredInlineSize: number,
): ToolbarDensity {
if (selectedDensity === "icon") return "icon";
if (!Number.isFinite(availableInlineSize) || availableInlineSize <= 0)
return currentDensity;
if (!Number.isFinite(requiredInlineSize) || requiredInlineSize <= 0)
return currentDensity;
if (currentDensity === "icon")
return availableInlineSize >= requiredInlineSize * 1.15
? selectedDensity
: currentDensity;
return availableInlineSize < requiredInlineSize * 1.1
? "icon"
: selectedDensity;
}
@@ -0,0 +1,28 @@
import { getTouchInputSettings } from "@/api/touch-input-settings-api";
import {
TOUCH_INPUT_DEFAULTS,
type TouchInputSettings,
} from "@/types/touch-input-settings";
let cached: TouchInputSettings | undefined;
let pending: Promise<TouchInputSettings> | undefined;
export function loadTouchInputSettings(): Promise<TouchInputSettings> {
if (cached) return Promise.resolve(cached);
pending ??= getTouchInputSettings()
.then((settings) => (cached = settings))
.catch(() => (cached = { ...TOUCH_INPUT_DEFAULTS }))
.finally(() => {
pending = undefined;
});
return pending;
}
export function cacheTouchInputSettings(settings: TouchInputSettings): void {
cached = settings;
}
export function resetTouchInputSettingsCache(): void {
cached = undefined;
pending = undefined;
}
@@ -0,0 +1,335 @@
import {
TOUCH_INPUT_DEFAULTS,
type TouchInputSettings,
} from "@/types/touch-input-settings";
export interface TouchWheelPoint {
clientX: number;
clientY: number;
}
export interface SyntheticWheelInput extends TouchWheelPoint {
deltaY: number;
deltaMode: number;
}
interface TouchWheelTiming {
now: () => number;
requestFrame: (callback: FrameRequestCallback) => number;
cancelFrame: (handle: number) => void;
momentumAllowed: () => boolean;
}
const defaultTiming: TouchWheelTiming = {
now: () => performance.now(),
requestFrame: (callback) => requestAnimationFrame(callback),
cancelFrame: (handle) => cancelAnimationFrame(handle),
momentumAllowed: () => true,
};
interface VelocitySample {
deltaY: number;
durationMs: number;
endedAt: number;
}
export class TouchWheelCoordinator {
private startY: number | undefined;
private lastPoint: TouchWheelPoint | undefined;
private pendingDeltaY = 0;
private scrolling = false;
private cancelled = false;
private velocitySamples: VelocitySample[] = [];
private lastMoveTime: number | undefined;
private momentumFrame: number | undefined;
private momentumRemainderY = 0;
constructor(
private readonly emitWheel: (input: SyntheticWheelInput) => void,
private readonly dragThresholdPx: number = TOUCH_INPUT_DEFAULTS.dragThresholdPx,
private readonly maxWheelDeltaPx: number = TOUCH_INPUT_DEFAULTS.maxWheelDeltaPx,
private readonly timing: TouchWheelTiming = defaultTiming,
private readonly settings: TouchInputSettings = TOUCH_INPUT_DEFAULTS,
) {}
start(point: TouchWheelPoint | undefined, time = this.timing.now()): void {
this.reset();
if (!point) {
this.cancelled = true;
return;
}
this.startY = point.clientY;
this.lastPoint = point;
this.lastMoveTime = time;
}
move(point: TouchWheelPoint | undefined, time = this.timing.now()): boolean {
if (this.cancelled || !point || !this.lastPoint) {
if (!point) this.cancel();
return false;
}
const deltaY = this.lastPoint.clientY - point.clientY;
this.pendingDeltaY += deltaY;
this.lastPoint = point;
if (this.lastMoveTime !== undefined && time > this.lastMoveTime) {
this.velocitySamples.push({
deltaY,
durationMs: time - this.lastMoveTime,
endedAt: time,
});
this.velocitySamples = this.velocitySamples.filter(
(sample) =>
time - sample.endedAt <= this.settings.momentumSampleWindowMs,
);
}
this.lastMoveTime = time;
if (!this.scrolling) {
if (Math.abs(point.clientY - this.startY!) < this.dragThresholdPx) {
return true;
}
this.scrolling = true;
}
this.flush(point);
return true;
}
end(time = this.timing.now()): void {
const point = this.lastPoint;
const velocity = this.scrolling ? this.recentVelocity(time) : 0;
this.clearGesture();
if (
point &&
this.timing.momentumAllowed() &&
Math.abs(velocity) >= this.settings.minimumVelocityPxPerMs
) {
this.startMomentum(point, velocity, time);
}
}
cancel(): void {
this.reset();
this.cancelled = true;
}
private flush(point: TouchWheelPoint): void {
while (Math.abs(this.pendingDeltaY) > this.maxWheelDeltaPx) {
const deltaY = Math.sign(this.pendingDeltaY) * this.maxWheelDeltaPx;
this.emitWheel({
...point,
deltaY,
deltaMode: WheelEvent.DOM_DELTA_PIXEL,
});
this.pendingDeltaY -= deltaY;
}
if (this.pendingDeltaY !== 0) {
this.emitWheel({
...point,
deltaY: this.pendingDeltaY,
deltaMode: WheelEvent.DOM_DELTA_PIXEL,
});
this.pendingDeltaY = 0;
}
}
private reset(): void {
this.stopMomentum();
this.clearGesture();
this.cancelled = false;
}
private clearGesture(): void {
this.startY = undefined;
this.lastPoint = undefined;
this.pendingDeltaY = 0;
this.scrolling = false;
this.velocitySamples = [];
this.lastMoveTime = undefined;
}
private recentVelocity(time: number): number {
const newestSampleTime = this.velocitySamples.at(-1)?.endedAt;
if (
newestSampleTime === undefined ||
time - newestSampleTime > this.settings.releaseGracePeriodMs
) {
return 0;
}
const samples = this.velocitySamples.filter(
(sample) =>
newestSampleTime - sample.endedAt <=
this.settings.momentumSampleWindowMs,
);
const duration = samples.reduce(
(total, sample) => total + sample.durationMs,
0,
);
if (duration === 0) return 0;
const distance = samples.reduce(
(total, sample) => total + sample.deltaY,
0,
);
return Math.max(
-this.settings.maximumVelocityPxPerMs,
Math.min(this.settings.maximumVelocityPxPerMs, distance / duration),
);
}
private startMomentum(
point: TouchWheelPoint,
initialVelocity: number,
startedAt: number,
): void {
let lastTime = startedAt;
let travelled = 0;
const step = (time: number) => {
if (!this.timing.momentumAllowed()) {
this.momentumFrame = undefined;
this.momentumRemainderY = 0;
return;
}
const elapsed = Math.max(0, time - startedAt);
const remainingTravel = this.settings.maximumTravelPx - travelled;
if (elapsed > this.settings.maximumDurationMs || remainingTravel <= 0) {
this.momentumFrame = undefined;
return;
}
const duration = Math.min(
Math.max(0, time - lastTime),
this.settings.maximumFrameIntervalMs,
);
if (duration > 0) {
const frameStart = Math.max(0, elapsed - duration);
const distance =
Math.abs(initialVelocity) *
this.settings.decayTimeMs *
(Math.exp(-frameStart / this.settings.decayTimeMs) -
Math.exp(-elapsed / this.settings.decayTimeMs));
const deltaY =
Math.sign(initialVelocity) * Math.min(distance, remainingTravel);
this.flushMomentum(point, deltaY);
travelled += Math.abs(deltaY);
}
lastTime = time;
if (
elapsed < this.settings.maximumDurationMs &&
travelled < this.settings.maximumTravelPx
) {
this.momentumFrame = this.timing.requestFrame(step);
} else {
this.momentumFrame = undefined;
}
};
this.momentumFrame = this.timing.requestFrame(step);
}
private flushMomentum(point: TouchWheelPoint, deltaY: number): void {
this.momentumRemainderY += deltaY;
const availableTicks = Math.trunc(
Math.abs(this.momentumRemainderY) / this.settings.pixelsPerTick,
);
const tickCount = Math.min(
availableTicks,
this.settings.maximumTicksPerFrame,
);
const direction = Math.sign(this.momentumRemainderY);
for (let index = 0; index < tickCount; index++) {
this.emitWheel({
...point,
deltaY: direction,
deltaMode: WheelEvent.DOM_DELTA_LINE,
});
}
this.momentumRemainderY -=
direction * tickCount * this.settings.pixelsPerTick;
}
private stopMomentum(): void {
if (this.momentumFrame !== undefined) {
this.timing.cancelFrame(this.momentumFrame);
this.momentumFrame = undefined;
}
this.momentumRemainderY = 0;
}
}
export function installTouchWheelCoordinator(
terminalElement: HTMLElement,
momentumAllowed: () => boolean = () => true,
settings: TouchInputSettings = TOUCH_INPUT_DEFAULTS,
): () => void {
if (!settings.enabled) return () => {};
const wheelElement = terminalElement.querySelector<HTMLElement>(
".xterm-scrollable-element",
);
if (!wheelElement) return () => {};
const coordinator = new TouchWheelCoordinator(
({ deltaY, deltaMode, clientX, clientY }) => {
wheelElement.dispatchEvent(
new WheelEvent("wheel", {
bubbles: true,
cancelable: true,
composed: true,
clientX,
clientY,
deltaMode,
deltaY,
}),
);
},
settings.dragThresholdPx,
settings.maxWheelDeltaPx,
{
...defaultTiming,
momentumAllowed: () =>
settings.momentumEnabled &&
momentumAllowed() &&
!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches,
},
settings,
);
const getSingleTouch = (event: TouchEvent): Touch | undefined =>
event.touches.length === 1
? (event.touches.item(0) ?? undefined)
: undefined;
const handleTouchStart = (event: TouchEvent) => {
const touch = getSingleTouch(event);
coordinator.start(touch);
};
const handleTouchMove = (event: TouchEvent) => {
if (coordinator.move(getSingleTouch(event))) event.preventDefault();
};
const handleTouchEnd = () => coordinator.end();
const handleTouchCancel = () => coordinator.cancel();
terminalElement.addEventListener("touchstart", handleTouchStart, {
passive: true,
});
terminalElement.addEventListener("touchmove", handleTouchMove, {
passive: false,
});
terminalElement.addEventListener("touchend", handleTouchEnd, {
passive: true,
});
terminalElement.addEventListener("touchcancel", handleTouchCancel, {
passive: true,
});
return () => {
coordinator.cancel();
terminalElement.removeEventListener("touchstart", handleTouchStart);
terminalElement.removeEventListener("touchmove", handleTouchMove);
terminalElement.removeEventListener("touchend", handleTouchEnd);
terminalElement.removeEventListener("touchcancel", handleTouchCancel);
};
}
+36 -26
View File
@@ -47,6 +47,7 @@ import {
type TmuxSearchResult,
} from "@/api/tmux-monitor-api";
import type { TerminalHandle } from "@/features/terminal/Terminal";
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling";
import { SessionTree, type SessionMetricsAgg } from "./SessionTree";
import { SearchResults } from "./SearchResults";
import { PanePreview } from "./PanePreview";
@@ -116,6 +117,8 @@ export function TmuxMonitor({
// stale (the user switched away while the request was in flight) and must
// not overwrite the current tree.
const activeHostRef = useRef<number | null>(null);
const overviewSignatureRef = useRef("");
const metricsSignatureRef = useRef("");
// True when the expanded-session set for the current host was restored from
// localStorage (or touched by the user) and must not be overwritten by the
// default expand-all behavior.
@@ -256,12 +259,15 @@ export function TmuxMonitor({
try {
const data = await getTmuxOverview(hostId);
if (activeHostRef.current !== hostId) return true;
const signature = JSON.stringify(data);
const changed = signature !== overviewSignatureRef.current;
overviewSignatureRef.current = signature;
setOverview(data);
setExpandedSessions((prev) => {
if (expandedRestoredRef.current || prev.size > 0) return prev;
return new Set(data.sessions.map((s) => s.name));
});
return true;
return changed;
} catch (err) {
if (activeHostRef.current !== hostId) return true;
if (!silent) {
@@ -293,6 +299,7 @@ export function TmuxMonitor({
);
}
}
if (silent) throw err;
return false;
} finally {
if (!silent && activeHostRef.current === hostId)
@@ -343,33 +350,36 @@ export function TmuxMonitor({
// Poll only while this app tab is shown: the component stays mounted when
// the user switches tabs (AppShell keeps tab content alive) and would
// otherwise keep running SSH execs against the host forever.
useEffect(() => {
if (selectedHostId === null || !isVisible) return;
const interval = setInterval(() => {
if (!document.hidden) loadOverview(selectedHostId, true);
}, OVERVIEW_POLL_MS);
return () => clearInterval(interval);
}, [selectedHostId, isVisible, loadOverview]);
useAdaptivePolling(
() =>
selectedHostId === null ? undefined : loadOverview(selectedHostId, true),
{
minIntervalMs: OVERVIEW_POLL_MS,
maxIntervalMs: 60_000,
stablePollsPerStep: 3,
},
selectedHostId !== null && isVisible,
{ runImmediately: false },
);
// -- metrics polling ------------------------------------------------------
useEffect(() => {
if (selectedHostId === null || !overview?.available || !isVisible) return;
let cancelled = false;
const load = () => {
if (document.hidden) return;
getTmuxMetrics(selectedHostId)
.then((m) => {
if (!cancelled) setMetrics(m);
})
.catch(() => {});
};
load();
const interval = setInterval(load, METRICS_POLL_MS);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [selectedHostId, overview?.available, isVisible]);
useAdaptivePolling(
async () => {
if (selectedHostId === null) return;
const next = await getTmuxMetrics(selectedHostId);
const signature = JSON.stringify(next);
const changed = signature !== metricsSignatureRef.current;
metricsSignatureRef.current = signature;
setMetrics(next);
return changed;
},
{
minIntervalMs: METRICS_POLL_MS,
maxIntervalMs: 60_000,
stablePollsPerStep: 3,
},
selectedHostId !== null && !!overview?.available && isVisible,
);
// -- keyboard shortcuts -----------------------------------------------------
// Gated on isVisible: the listener is global, and a hidden-but-mounted tab
+11 -5
View File
@@ -1,16 +1,22 @@
import type { TFunction } from "i18next";
export function formatRelativeTime(
unixSeconds: number,
nowMs: number,
t: (k: string, o?: object) => string,
t: TFunction,
): string {
if (!unixSeconds) return "";
const diff = Math.max(0, nowMs / 1000 - unixSeconds);
if (diff < 60) return t("tmuxMonitor.timeJustNow");
if (diff < 60) return String(t("tmuxMonitor.timeJustNow"));
if (diff < 3600)
return t("tmuxMonitor.timeMinutes", { count: Math.floor(diff / 60) });
return String(
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) });
return String(
t("tmuxMonitor.timeHours", { count: Math.floor(diff / 3600) }),
);
return String(t("tmuxMonitor.timeDays", { count: Math.floor(diff / 86400) }));
}
export function formatMem(kb: number): string {
+18 -6
View File
@@ -2,6 +2,7 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
import { TunnelTab } from "@/features/tunnel/TunnelTab.tsx";
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
import type { Host } from "@/types/ui-types";
import type { SSHHost } from "@/types";
@@ -25,6 +26,11 @@ function sshHostToMinimalHost(h: SSHHost): Host {
pin: h.pin ?? false,
authType: h.authType,
enableTerminal: h.enableTerminal ?? false,
enableCommandHistory: h.enableCommandHistory ?? false,
enableProxmox: h.enableProxmox ?? false,
enableProxmoxStats: h.enableProxmoxStats ?? false,
enableTmuxMonitor: h.enableTmuxMonitor ?? false,
enableTerminalToolbar: h.enableTerminalToolbar ?? true,
enableTunnel: h.enableTunnel ?? false,
enableFileManager: h.enableFileManager ?? false,
enableDocker: h.enableDocker ?? false,
@@ -45,19 +51,25 @@ const TunnelApp: React.FC<TunnelAppProps> = ({ hostId }) => {
const { t } = useTranslation();
return (
<FullScreenAppWrapper hostId={hostId}>
{(hostConfig, loading) => {
if (loading) {
{(hostConfig, phase) => {
if (phase === "loading") {
return (
<div className="flex items-center justify-center h-full">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto" />
<div className="relative h-full w-full">
<ConnectionScreen
status="connecting"
message={t("hosts.loadingHost")}
/>
</div>
);
}
if (!hostConfig) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-muted-foreground">{t("hosts.hostNotFound")}</p>
<div className="relative h-full w-full">
<ConnectionScreen
status="disconnected"
message={t("hosts.hostNotFound")}
/>
</div>
);
}
@@ -1,156 +0,0 @@
import { Button } from "@/components/button.tsx";
import type { TunnelStatus } from "@/types/index.js";
import {
AlertCircle,
Loader2,
Play,
Square,
Wifi,
WifiOff,
} from "lucide-react";
import { useTranslation } from "react-i18next";
type TunnelInlineControlsProps = {
status?: TunnelStatus;
loading?: boolean;
onStart?: () => void;
onStop?: () => void;
startDisabled?: boolean;
startDisabledReason?: string;
};
function getStatusKind(status?: TunnelStatus) {
const value = status?.status?.toUpperCase() || "DISCONNECTED";
if (value === "CONNECTED") return "connected";
if (value === "ERROR" || value === "FAILED") return "error";
if (
value === "CONNECTING" ||
value === "DISCONNECTING" ||
value === "RETRYING" ||
value === "WAITING"
) {
return "connecting";
}
return "disconnected";
}
function getStatusTitle(
status: TunnelStatus | undefined,
statusText: string,
t: ReturnType<typeof useTranslation>["t"],
) {
if (!status) return statusText;
const details = [];
if (status.reason) details.push(status.reason);
if (status.retryCount && status.maxRetries) {
details.push(
t("tunnels.attempt", {
current: status.retryCount,
max: status.maxRetries,
}),
);
}
if (status.nextRetryIn) {
details.push(
t("tunnels.nextRetryIn", {
seconds: status.nextRetryIn,
}),
);
}
if (status.errorType && !status.reason) details.push(status.errorType);
return details.length > 0 ? details.join("\n") : statusText;
}
export function TunnelInlineControls({
status,
loading = false,
onStart,
onStop,
startDisabled,
startDisabledReason,
}: TunnelInlineControlsProps) {
const { t } = useTranslation();
const kind = getStatusKind(status);
const isDisconnected = kind === "disconnected";
const statusText =
kind === "connected"
? t("tunnels.connected")
: kind === "connecting"
? t("tunnels.connecting")
: kind === "error"
? t("tunnels.error")
: t("tunnels.disconnected");
const title = getStatusTitle(status, statusText, t);
const statusClass =
kind === "connected"
? "text-accent-brand border-accent-brand/40 bg-accent-brand/10"
: kind === "connecting"
? "text-blue-400 border-blue-400/40 bg-blue-400/10"
: kind === "error"
? "text-destructive border-destructive/40 bg-destructive/10"
: "text-muted-foreground border-border bg-muted/30";
const statusIcon =
kind === "connected" ? (
<Wifi className="size-3" />
) : kind === "connecting" ? (
<Loader2 className="size-3 animate-spin" />
) : kind === "error" ? (
<AlertCircle className="size-3" />
) : (
<WifiOff className="size-3" />
);
return (
<div className="flex flex-wrap items-center justify-end gap-2">
<span
className={`inline-flex h-8 items-center gap-1.5 border px-2 text-[10px] font-bold uppercase tracking-wide ${statusClass}`}
title={title}
>
{statusIcon}
{statusText}
</span>
{loading ? (
<Button
type="button"
size="sm"
variant="outline"
disabled
className="h-8 px-3 text-xs text-muted-foreground border-border"
>
<Loader2 className="size-3 mr-1 animate-spin" />
{isDisconnected ? t("tunnels.start") : t("tunnels.stop")}
</Button>
) : isDisconnected ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={onStart}
disabled={startDisabled}
title={startDisabled ? startDisabledReason : undefined}
className="h-8 px-3 text-xs text-accent-brand border-accent-brand/40 hover:bg-accent-brand/10 hover:text-accent-brand"
>
<Play className="size-3 mr-1" />
{t("tunnels.start")}
</Button>
) : (
<Button
type="button"
size="sm"
variant="outline"
onClick={onStop}
className="h-8 px-3 text-xs text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive"
>
<Square className="size-3 mr-1" />
{t("tunnels.stop")}
</Button>
)}
</div>
);
}
@@ -1,78 +0,0 @@
import { useTranslation } from "react-i18next";
import type { TunnelMode } from "@/types/index.js";
type TunnelModeSelectorProps = {
mode: TunnelMode;
scope: "client" | "server";
onChange: (mode: TunnelMode) => void;
};
export function TunnelModeSelector({
mode,
scope,
onChange,
}: TunnelModeSelectorProps) {
const { t } = useTranslation();
const options: Array<{
value: TunnelMode;
label: string;
description: string;
}> = [
{
value: "local",
label: t("tunnels.typeLocal"),
description:
scope === "client"
? t("tunnels.typeClientLocalDesc")
: t("tunnels.typeServerLocalDesc"),
},
{
value: "remote",
label: t("tunnels.typeRemote"),
description:
scope === "client"
? t("tunnels.typeClientRemoteDesc")
: t("tunnels.typeServerRemoteDesc"),
},
{
value: "dynamic",
label: t("tunnels.typeDynamic"),
description:
scope === "client"
? t("tunnels.typeClientDynamicDesc")
: t("tunnels.typeDynamicDesc"),
},
];
return (
<div className="grid gap-2 lg:grid-cols-3">
{options.map((option) => (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
className={`flex items-start gap-2.5 border p-3 text-left transition-colors ${
mode === option.value
? "border-accent-brand bg-accent-brand/5 text-foreground"
: "border-border bg-muted/20 text-muted-foreground hover:border-border/80 hover:bg-muted/30"
}`}
>
<div
className={`mt-0.5 size-3.5 shrink-0 rounded-full border-2 ${
mode === option.value
? "border-accent-brand bg-accent-brand"
: "border-muted-foreground/40"
}`}
/>
<div className="flex flex-col gap-0.5">
<span className="text-xs font-semibold">{option.label}</span>
<span className="text-[10px] text-muted-foreground leading-tight">
{option.description}
</span>
</div>
</button>
))}
</div>
);
}