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
+1013 -217
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -36,7 +36,7 @@ export async function updateAcmeSslSettings(
}
export async function requestAcmeCertificate(): Promise<
AcmeSettings & { success: boolean }
AcmeSettings & { success: boolean; reloadMessage?: string }
> {
try {
const response = await authApi.post("/users/acme-ssl-request", {});
@@ -49,7 +49,7 @@ export async function requestAcmeCertificate(): Promise<
export async function uploadManualSslCertificate(payload: {
certificate: string;
privateKey: string;
}): Promise<AcmeSettings & { success: boolean }> {
}): Promise<AcmeSettings & { success: boolean; reloadMessage?: string }> {
try {
const response = await authApi.post("/users/manual-ssl-upload", payload);
return response.data;
+214
View File
@@ -0,0 +1,214 @@
import { authApi, handleApiError } from "@/main-axios";
export type AiProviderType =
"ollama" | "anthropic" | "openai" | "gemini" | "openai_compatible";
export interface AiProvider {
id: number;
providerType: AiProviderType;
label: string;
baseUrl: string | null;
/** The first few characters only; the key itself never leaves the server. */
apiKeyPrefix: string | null;
defaultModel: string | null;
enabled: boolean;
createdAt: string;
}
export interface AiConversation {
id: number;
title: string | null;
providerId: number | null;
model: string | null;
createdAt: string;
updatedAt: string;
}
export interface AiMessage {
id: number;
conversationId: number;
role: "user" | "assistant" | "tool";
content: string;
toolCalls: string | null;
createdAt: string;
}
export interface AiProposal {
id: number;
conversationId: number;
kind: string;
summary: string | null;
payload: string;
status: "pending" | "applied" | "rejected" | "expired";
resultSummary: string | null;
createdAt: string;
}
export interface AiStatus {
globallyEnabled: boolean;
enabled: boolean;
allowReadOnlyCommands: boolean;
}
export async function getAiStatus(): Promise<AiStatus> {
try {
return (await authApi.get("/ai/status")).data;
} catch (error) {
throw handleApiError(error, "get AI status");
}
}
export async function getAiProviders(): Promise<AiProvider[]> {
try {
return (await authApi.get("/ai/providers")).data.providers;
} catch (error) {
throw handleApiError(error, "list AI providers");
}
}
export async function createAiProvider(input: {
providerType: AiProviderType;
label: string;
baseUrl?: string | null;
apiKey?: string | null;
defaultModel?: string | null;
}): Promise<AiProvider> {
try {
return (await authApi.post("/ai/providers", input)).data.provider;
} catch (error) {
throw handleApiError(error, "create AI provider");
}
}
export async function updateAiProvider(
id: number,
input: Partial<{
label: string;
baseUrl: string | null;
apiKey: string | null;
defaultModel: string | null;
enabled: boolean;
}>,
): Promise<AiProvider> {
try {
return (await authApi.patch(`/ai/providers/${id}`, input)).data.provider;
} catch (error) {
throw handleApiError(error, "update AI provider");
}
}
export async function deleteAiProvider(id: number): Promise<void> {
try {
await authApi.delete(`/ai/providers/${id}`);
} catch (error) {
throw handleApiError(error, "delete AI provider");
}
}
/**
* Model list for a provider that may not be saved yet, so the add form can
* fill its picker before anything is persisted.
*/
export async function probeAiModels(input: {
providerType: AiProviderType;
baseUrl?: string | null;
apiKey?: string | null;
providerId?: number | null;
}): Promise<{ models: string[]; source: "live" | "fallback" | "none" }> {
try {
return (await authApi.post("/ai/probe-models", input)).data;
} catch (error) {
throw handleApiError(error, "detect models");
}
}
export async function getAiProviderModels(id: number): Promise<string[]> {
try {
return (await authApi.get(`/ai/providers/${id}/models`)).data.models;
} catch (error) {
throw handleApiError(error, "list provider models");
}
}
export async function getAiConversations(): Promise<AiConversation[]> {
try {
return (await authApi.get("/ai/conversations")).data.conversations;
} catch (error) {
throw handleApiError(error, "list AI conversations");
}
}
export async function getAiConversation(id: number): Promise<{
conversation: AiConversation;
messages: AiMessage[];
proposals: AiProposal[];
}> {
try {
return (await authApi.get(`/ai/conversations/${id}`)).data;
} catch (error) {
throw handleApiError(error, "load AI conversation");
}
}
export async function deleteAiConversation(id: number): Promise<void> {
try {
await authApi.delete(`/ai/conversations/${id}`);
} catch (error) {
throw handleApiError(error, "delete AI conversation");
}
}
export async function applyAiProposal(
id: number,
): Promise<{ success: boolean; summary: string }> {
try {
return (await authApi.post(`/ai/proposals/${id}/apply`)).data;
} catch (error) {
throw handleApiError(error, "apply AI proposal");
}
}
export async function rejectAiProposal(id: number): Promise<void> {
try {
await authApi.post(`/ai/proposals/${id}/reject`);
} catch (error) {
throw handleApiError(error, "reject AI proposal");
}
}
// --- admin ---
export async function getAiGloballyEnabled(): Promise<boolean> {
try {
return (await authApi.get("/users/ai-enabled")).data.enabled;
} catch (error) {
throw handleApiError(error, "get AI enabled setting");
}
}
export async function setAiGloballyEnabled(enabled: boolean): Promise<boolean> {
try {
return (await authApi.patch("/users/ai-enabled", { enabled })).data.enabled;
} catch (error) {
throw handleApiError(error, "update AI enabled setting");
}
}
export async function getAiPrivateEndpoints(): Promise<string[]> {
try {
return (await authApi.get("/users/ai-private-endpoints")).data.hosts;
} catch (error) {
throw handleApiError(error, "get AI endpoint allowlist");
}
}
export async function setAiPrivateEndpoints(
hosts: string[],
): Promise<string[]> {
try {
return (await authApi.patch("/users/ai-private-endpoints", { hosts })).data
.hosts;
} catch (error) {
throw handleApiError(error, "update AI endpoint allowlist");
}
}
+19 -4
View File
@@ -4,7 +4,7 @@ export interface NotificationChannel {
id: number;
userId: string;
name: string;
type: "webhook" | "ntfy";
type: "webhook" | "ntfy" | "discord";
config: string;
enabled: boolean;
createdAt: string;
@@ -69,8 +69,19 @@ export async function getNotificationChannels(): Promise<
return res.data;
}
export type NotificationChannelPayload = Partial<
Omit<NotificationChannel, "config">
> & {
// When creating/updating the channel the UI may pass a parsed object
// for `config` (e.g., { url, username }) — the backend stores it as
// a JSON string. Accept either a string or any structured object here.
// Use `unknown` to allow the component-local config types to be passed
// without importing them into this module.
config: string | unknown;
};
export async function createNotificationChannel(
data: Partial<NotificationChannel>,
data: NotificationChannelPayload,
): Promise<NotificationChannel> {
const res = await rbacApi.post("/notification-channels", data);
return res.data;
@@ -78,7 +89,7 @@ export async function createNotificationChannel(
export async function updateNotificationChannel(
id: number,
data: Partial<NotificationChannel>,
data: NotificationChannelPayload,
): Promise<NotificationChannel> {
const res = await rbacApi.put(`/notification-channels/${id}`, data);
return res.data;
@@ -89,7 +100,11 @@ export async function deleteNotificationChannel(id: number): Promise<void> {
}
export async function testNotificationChannel(id: number): Promise<void> {
await rbacApi.post(`/notification-channels/${id}/test`);
const res = await rbacApi.post(`/notification-channels/${id}/test`);
const data = res.data as { success?: boolean; error?: string };
if (data && data.success === false) {
throw new Error(data.error || "Test notification failed");
}
}
function mapRule(r: Record<string, unknown>): AlertRule {
+2 -2
View File
@@ -1,6 +1,6 @@
import { authApi, handleApiError } from "@/main-axios";
export interface AuditLog {
export type AuditLog = {
id: number;
userId: string;
username: string;
@@ -14,7 +14,7 @@ export interface AuditLog {
success: boolean;
errorMessage: string | null;
timestamp: string;
}
};
export interface AuditLogFilters {
page?: number;
+143
View File
@@ -0,0 +1,143 @@
import { authApi, handleApiError } from "@/main-axios";
import type {
AutomationDefinition,
ConcurrencyPolicy,
RunStatus,
StepStatus,
StepType,
TriggerKind,
} from "@/types/automations";
export interface AutomationRow {
id: number;
user_id: string;
name: string;
description: string | null;
enabled: number;
definition: AutomationDefinition | null;
definition_version: number;
concurrency_policy: ConcurrencyPolicy;
max_run_seconds: number;
dry_run: number;
last_run_at: string | null;
last_run_status: RunStatus | null;
created_at: string;
updated_at: string;
channels: number[];
/** Returned once, only when a webhook automation is created. */
webhookToken?: string;
}
export interface AutomationRunRow {
id: number;
automation_id: number;
user_id: string;
trigger_type: TriggerKind | "manual";
trigger_context: string | null;
status: RunStatus;
started_at: string;
finished_at: string | null;
duration_ms: number | null;
error: string | null;
dry_run: number;
parent_run_id: number | null;
automation_name?: string | null;
}
export interface AutomationRunStepRow {
id: number;
run_id: number;
step_index: number;
step_id: string;
step_type: StepType;
status: StepStatus;
started_at: string;
finished_at: string | null;
output: string | null;
error: string | null;
truncated: number;
}
export interface AutomationRunOutcome {
runId: number | null;
status: RunStatus;
error?: string;
}
export interface AutomationInput {
name: string;
description?: string | null;
enabled?: boolean;
definition: AutomationDefinition;
concurrencyPolicy?: ConcurrencyPolicy;
channels?: number[];
}
export async function listAutomations(): Promise<AutomationRow[]> {
try {
return (await authApi.get("/automations")).data;
} catch (error) {
throw handleApiError(error, "fetch automations");
}
}
export async function createAutomation(
input: AutomationInput,
): Promise<AutomationRow> {
try {
return (await authApi.post("/automations", input)).data;
} catch (error) {
throw handleApiError(error, "create automation");
}
}
export async function updateAutomation(
id: number,
input: Partial<AutomationInput>,
): Promise<AutomationRow> {
try {
return (await authApi.put(`/automations/${id}`, input)).data;
} catch (error) {
throw handleApiError(error, "update automation");
}
}
export async function deleteAutomation(id: number): Promise<void> {
try {
await authApi.delete(`/automations/${id}`);
} catch (error) {
throw handleApiError(error, "delete automation");
}
}
export async function runAutomation(
id: number,
options: { dryRun?: boolean } = {},
): Promise<AutomationRunOutcome> {
try {
return (await authApi.post(`/automations/${id}/run`, options)).data;
} catch (error) {
throw handleApiError(error, "run automation");
}
}
export async function listAutomationRuns(
options: { automationId?: number; limit?: number; offset?: number } = {},
): Promise<AutomationRunRow[]> {
try {
return (await authApi.get("/automations/runs/history", { params: options }))
.data;
} catch (error) {
throw handleApiError(error, "fetch automation runs");
}
}
export async function listAutomationRunSteps(
runId: number,
): Promise<AutomationRunStepRow[]> {
try {
return (await authApi.get(`/automations/runs/${runId}/steps`)).data;
} catch (error) {
throw handleApiError(error, "fetch run steps");
}
}
@@ -0,0 +1,29 @@
import { authApi, handleApiError } from "@/main-axios";
import {
sanitizeCredentialSidebarPreferences,
type CredentialSidebarPreferences,
} from "@/types/credential-sidebar-preferences";
// CREDENTIAL SIDEBAR PREFERENCES API
// ============================================================================
export async function getCredentialSidebarPreferences(): Promise<CredentialSidebarPreferences> {
try {
const response = await authApi.get("/credential-sidebar/preferences");
return sanitizeCredentialSidebarPreferences(response.data?.preferences);
} catch (error) {
handleApiError(error, "fetch credential sidebar preferences");
throw error;
}
}
export async function saveCredentialSidebarPreferences(
preferences: CredentialSidebarPreferences,
): Promise<void> {
try {
await authApi.put("/credential-sidebar/preferences", preferences);
} catch (error) {
handleApiError(error, "save credential sidebar preferences");
throw error;
}
}
+66 -5
View File
@@ -1,6 +1,11 @@
import { authApi, handleApiError, sshHostApi } from "@/main-axios";
import type { SSHFolder } from "@/types/index";
import { sshLogger } from "@/lib/frontend-logger";
import {
getCachedSSHFolders,
invalidateSSHFoldersCache,
invalidateHostsAndStatusCaches,
} from "@/lib/hosts-request-cache";
export async function getCredentials(): Promise<
Record<string, unknown>[] | Record<string, unknown>
@@ -174,6 +179,7 @@ export async function renameFolder(
oldName,
newName,
});
invalidateSSHFoldersCache();
return response.data;
} catch (error) {
handleApiError(error, "rename folder");
@@ -186,14 +192,17 @@ export async function getSSHFolders(): Promise<SSHFolder[]> {
operation: "fetch_ssh_folders",
});
const response = await authApi.get("/host/folders");
const folders = await getCachedSSHFolders(async () => {
const response = await authApi.get("/host/folders");
return response.data;
});
sshLogger.success("SSH folders fetched successfully", {
operation: "fetch_ssh_folders",
count: response.data.length,
count: folders.length,
});
return response.data;
return folders;
} catch (error) {
sshLogger.error("Failed to fetch SSH folders", error, {
operation: "fetch_ssh_folders",
@@ -225,6 +234,8 @@ export async function updateFolderMetadata(
credentialId,
});
invalidateSSHFoldersCache();
sshLogger.success("Folder metadata updated successfully", {
operation: "update_folder_metadata",
name,
@@ -239,6 +250,21 @@ export async function updateFolderMetadata(
}
}
export async function reorderFolders(
positions: { name: string; sortOrder: number }[],
): Promise<{ updated: number }> {
try {
const response = await authApi.put("/host/folders/reorder", {
positions,
});
invalidateSSHFoldersCache();
return response.data;
} catch (error) {
handleApiError(error, "reorder folders");
throw error;
}
}
export async function deleteAllHostsInFolder(
folderName: string,
): Promise<{ deletedCount: number }> {
@@ -252,6 +278,8 @@ export async function deleteAllHostsInFolder(
`/host/folders/${encodeURIComponent(folderName)}/hosts`,
);
invalidateHostsAndStatusCaches();
sshLogger.success("All hosts in folder deleted successfully", {
operation: "delete_folder_hosts",
folderName,
@@ -278,12 +306,27 @@ export async function renameCredentialFolder(
oldName,
newName,
});
invalidateSSHFoldersCache();
return response.data;
} catch (error) {
throw handleApiError(error, "rename credential folder");
}
}
export async function reorderCredentials(
positions: { id: number; sortOrder: number }[],
): Promise<{ updated: number }> {
try {
const response = await authApi.put("/credentials/reorder", {
positions,
});
return response.data;
} catch (error) {
handleApiError(error, "reorder credentials");
throw error;
}
}
export async function detectKeyType(
privateKey: string,
keyPassword?: string,
@@ -329,10 +372,16 @@ export async function validateKeyPair(
}
}
export interface GeneratedPublicKey {
success?: boolean;
publicKey?: string;
error?: string;
}
export async function generatePublicKeyFromPrivate(
privateKey: string,
keyPassword?: string,
): Promise<Record<string, unknown>> {
): Promise<GeneratedPublicKey> {
try {
const response = await authApi.post("/credentials/generate-public-key", {
privateKey,
@@ -344,11 +393,23 @@ export async function generatePublicKeyFromPrivate(
}
}
export interface GeneratedKeyPair {
success: boolean;
privateKey?: string;
publicKey?: string;
keyType?: string;
format?: string;
algorithm?: string;
keySize?: number;
curve?: string;
error?: string;
}
export async function generateKeyPair(
keyType: "ssh-ed25519" | "ssh-rsa" | "ecdsa-sha2-nistp256",
keySize?: number,
passphrase?: string,
): Promise<Record<string, unknown>> {
): Promise<GeneratedKeyPair> {
try {
const response = await authApi.post("/credentials/generate-key-pair", {
keyType,
+12 -3
View File
@@ -1,11 +1,20 @@
import { authApi, handleApiError } from "@/main-axios";
/** Row shape shared by the recent / pinned / shortcut list endpoints. */
export interface FileManagerEntry {
id: number;
name: string;
path: string;
lastOpened?: string;
[key: string]: unknown;
}
// FILE MANAGER DATA
// ============================================================================
export async function getRecentFiles(
hostId: number,
): Promise<Record<string, unknown>> {
): Promise<FileManagerEntry[]> {
try {
const response = await authApi.get("/host/file_manager/recent", {
params: { hostId },
@@ -52,7 +61,7 @@ export async function removeRecentFile(
export async function getPinnedFiles(
hostId: number,
): Promise<Record<string, unknown>> {
): Promise<FileManagerEntry[]> {
try {
const response = await authApi.get("/host/file_manager/pinned", {
params: { hostId },
@@ -99,7 +108,7 @@ export async function removePinnedFile(
export async function getFolderShortcuts(
hostId: number,
): Promise<Record<string, unknown>> {
): Promise<FileManagerEntry[]> {
try {
const response = await authApi.get("/host/file_manager/shortcuts", {
params: { hostId },
+247
View File
@@ -0,0 +1,247 @@
import { authApi, handleApiError } from "@/main-axios";
export interface FleetRow {
id: number;
userId: string;
name: string;
description: string | null;
color: string | null;
icon: string | null;
tagRules: string[];
syncId: string | null;
createdAt: string;
updatedAt: string;
memberCount: number;
}
export interface FleetMemberRow {
id: number;
name: string;
ip: string;
tags: string[];
static: boolean;
permissionLevel: "connect" | "view" | "edit" | "manage" | null;
}
export interface FleetHostResult {
hostId: number;
hostName: string;
success: boolean;
output?: string;
error?: string;
}
export interface FleetInventoryRecord {
id: number;
hostId: number;
userId: string;
osPrettyName: string | null;
kernel: string | null;
architecture: string | null;
hostname: string | null;
uptimeSeconds: number | null;
ip: string | null;
packageManager: string | null;
collectedAt: string;
}
export interface FleetInventoryEntry {
hostId: number;
hostName: string;
inventory: FleetInventoryRecord | null;
}
export type FleetPackageAction = "install" | "remove" | "upgrade-all";
export async function listFleets(): Promise<FleetRow[]> {
try {
const response = await authApi.get("/fleets");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch fleets");
}
}
export async function createFleet(fleetData: {
name: string;
description?: string | null;
color?: string | null;
icon?: string | null;
tagRules?: string[];
}): Promise<FleetRow> {
try {
const response = await authApi.post("/fleets", fleetData);
return response.data;
} catch (error) {
throw handleApiError(error, "create fleet");
}
}
export async function updateFleet(
fleetId: number,
fleetData: {
name?: string;
description?: string | null;
color?: string | null;
icon?: string | null;
tagRules?: string[];
},
): Promise<FleetRow> {
try {
const response = await authApi.patch(`/fleets/${fleetId}`, fleetData);
return response.data;
} catch (error) {
throw handleApiError(error, "update fleet");
}
}
export async function deleteFleet(
fleetId: number,
): Promise<{ success: boolean }> {
try {
const response = await authApi.delete(`/fleets/${fleetId}`);
return response.data;
} catch (error) {
throw handleApiError(error, "delete fleet");
}
}
export async function getFleetMembers(
fleetId: number,
): Promise<FleetMemberRow[]> {
try {
const response = await authApi.get(`/fleets/${fleetId}/members`);
return response.data;
} catch (error) {
throw handleApiError(error, "fetch fleet members");
}
}
export async function addFleetMember(
fleetId: number,
hostId: number,
): Promise<{ success: boolean }> {
try {
const response = await authApi.post(`/fleets/${fleetId}/members`, {
hostId,
});
return response.data;
} catch (error) {
throw handleApiError(error, "add fleet member");
}
}
export async function removeFleetMember(
fleetId: number,
hostId: number,
): Promise<{ success: boolean }> {
try {
const response = await authApi.delete(
`/fleets/${fleetId}/members/${hostId}`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "remove fleet member");
}
}
export async function runFleetCommand(
fleetId: number,
command: string,
inputValues?: Record<string, string>,
): Promise<{ results: FleetHostResult[] }> {
try {
const response = await authApi.post(`/fleets/${fleetId}/execute`, {
command,
...(inputValues ? { inputValues } : {}),
});
return response.data;
} catch (error) {
throw handleApiError(error, "run fleet command");
}
}
export async function pushFleetFile(
fleetId: number,
file: File,
remotePath: string,
): Promise<{ results: FleetHostResult[] }> {
try {
const form = new FormData();
form.append("file", file);
form.append("remotePath", remotePath);
const response = await authApi.post(
`/fleets/${fleetId}/transfer/push`,
form,
{ headers: { "Content-Type": "multipart/form-data" } },
);
return response.data;
} catch (error) {
throw handleApiError(error, "push file to fleet");
}
}
export async function pullFleetFile(
fleetId: number,
remotePath: string,
): Promise<{ results: FleetHostResult[]; blob: Blob; fileName: string }> {
try {
const response = await authApi.post(
`/fleets/${fleetId}/transfer/pull`,
{ remotePath },
{ responseType: "blob" },
);
const resultsHeader = response.headers["x-fleet-transfer-results"];
const results: FleetHostResult[] = resultsHeader
? JSON.parse(atob(resultsHeader))
: [];
const disposition = response.headers["content-disposition"] as
string | undefined;
const match = disposition?.match(/filename="([^"]+)"/);
const fileName = match?.[1] ?? "fleet-transfer.zip";
return { results, blob: response.data, fileName };
} catch (error) {
throw handleApiError(error, "pull file from fleet");
}
}
export async function getFleetInventory(
fleetId: number,
): Promise<FleetInventoryEntry[]> {
try {
const response = await authApi.get(`/fleets/${fleetId}/inventory`);
return response.data;
} catch (error) {
throw handleApiError(error, "fetch fleet inventory");
}
}
export async function refreshFleetInventory(
fleetId: number,
): Promise<{ results: FleetHostResult[] }> {
try {
const response = await authApi.post(`/fleets/${fleetId}/inventory`);
return response.data;
} catch (error) {
throw handleApiError(error, "refresh fleet inventory");
}
}
export async function runFleetPackageAction(
fleetId: number,
action: FleetPackageAction,
packageName?: string,
): Promise<{ results: FleetHostResult[] }> {
try {
const response = await authApi.post(`/fleets/${fleetId}/packages`, {
action,
...(packageName ? { package: packageName } : {}),
});
return response.data;
} catch (error) {
throw handleApiError(error, "run fleet package action");
}
}
+22 -60
View File
@@ -5,6 +5,7 @@ import {
isElectron,
} from "@/main-axios";
import type { AxiosInstance } from "axios";
import type { GuacamoleConfig } from "@/types/guacamole-config";
/**
* The embedded desktop backend does not bundle guacd, which is why
@@ -25,65 +26,7 @@ export interface GuacamoleTokenRequest {
domain?: string;
security?: string;
ignoreCert?: boolean;
guacamoleConfig?: {
colorDepth?: number;
width?: number;
height?: number;
dpi?: number;
resizeMethod?: string;
forceLossless?: boolean;
disableAudio?: boolean;
enableAudioInput?: boolean;
enableWallpaper?: boolean;
enableTheming?: boolean;
enableFontSmoothing?: boolean;
enableFullWindowDrag?: boolean;
enableDesktopComposition?: boolean;
enableMenuAnimations?: boolean;
disableBitmapCaching?: boolean;
disableOffscreenCaching?: boolean;
disableGlyphCaching?: boolean;
disableGfx?: boolean;
enablePrinting?: boolean;
printerName?: string;
enableDrive?: boolean;
driveName?: string;
drivePath?: string;
createDrivePath?: boolean;
disableDownload?: boolean;
disableUpload?: boolean;
enableTouch?: boolean;
clientName?: string;
console?: boolean;
initialProgram?: string;
serverLayout?: string;
timezone?: string;
gatewayHostname?: string;
gatewayPort?: number;
gatewayUsername?: string;
gatewayPassword?: string;
gatewayDomain?: string;
remoteApp?: string;
remoteAppDir?: string;
remoteAppArgs?: string;
normalizeClipboard?: string;
disableCopy?: boolean;
disablePaste?: boolean;
cursor?: string;
swapRedBlue?: boolean;
readOnly?: boolean;
recordingPath?: string;
recordingName?: string;
createRecordingPath?: boolean;
recordingExcludeOutput?: boolean;
recordingExcludeMouse?: boolean;
recordingIncludeKeys?: boolean;
wolSendPacket?: boolean;
wolMacAddr?: string;
wolBroadcastAddr?: string;
wolUdpPort?: number;
wolWaitTime?: number;
};
guacamoleConfig?: GuacamoleConfig;
}
export interface GuacamoleTokenResponse {
@@ -95,6 +38,18 @@ type GuacamoleConfigSource = {
guacamoleConfig?: string | Record<string, unknown> | null;
};
export function parseGuacamoleConfig(
config?: string | GuacamoleConfig | null,
): GuacamoleConfig {
if (!config) return {};
if (typeof config !== "string") return config;
try {
return JSON.parse(config) as GuacamoleConfig;
} catch {
return {};
}
}
export function getGuacamoleDpi(
source?: GuacamoleConfigSource,
): number | undefined {
@@ -225,7 +180,11 @@ export async function getGuacamoleToken(
export async function getGuacamoleTokenFromHost(
hostId: number,
protocol?: "rdp" | "vnc" | "telnet",
promptedCredentials?: { username?: string; password?: string },
promptedCredentials?: {
username?: string;
password?: string;
domain?: string;
},
): Promise<GuacamoleTokenResponse> {
try {
const response = await guacamoleApi().post(
@@ -238,6 +197,9 @@ export async function getGuacamoleTokenFromHost(
...(promptedCredentials?.password
? { promptedPassword: promptedCredentials.password }
: {}),
...(promptedCredentials
? { promptedDomain: promptedCredentials.domain ?? "" }
: {}),
},
);
return response.data;
-9
View File
@@ -70,12 +70,3 @@ export async function saveHomepageLayout(
throw handleApiError(error, "save homepage layout");
}
}
export function getHomepageFaviconUrl(url: string): string {
try {
const base = (homepageApi.defaults.baseURL ?? "").replace(/\/$/, "");
return `${base}/favicon?url=${encodeURIComponent(url)}`;
} catch {
return "";
}
}
+50 -18
View File
@@ -4,9 +4,13 @@ import {
statsApi,
getRemoteStatsApi,
isElectron,
sshHostApi,
} from "@/main-axios";
import type { ServerMetrics, ServerStatus } from "@/main-axios";
import { getCachedServerStatuses } from "@/lib/hosts-request-cache";
import { resolveConnectionOrigin } from "@/lib/connection-origin";
import type { SSHHost } from "@/types/index";
import { createKeyedRequestCache } from "@/lib/keyed-request-cache";
// Metrics collection/viewer registration below (startMetricsPolling,
// registerMetricsViewer, etc.) is NOT origin-routed: the backend that
@@ -50,6 +54,8 @@ type ConnectErrorResponse = {
// SERVER STATISTICS
// ============================================================================
const metricsCache = createKeyedRequestCache<ServerMetrics | null>(1_500, 100);
/**
* Progressive retry schedule for the background /status poll.
*
@@ -100,6 +106,26 @@ export async function getAllServerStatuses(): Promise<
return getCachedServerStatuses(async () => {
let lastError: unknown = null;
let localStatuses: Record<number, ServerStatus> = {};
let localHostIds: number[] | null = null;
if (isElectron()) {
try {
const response = await sshHostApi.get<SSHHost[]>("/db/host");
const defaultOrigin = await resolveConnectionOrigin({
connectionType: "ssh",
connectionOrigin: null,
});
localHostIds = (response.data || [])
.filter(
(host) => (host.connectionOrigin ?? defaultOrigin) === "local",
)
.map((host) => host.id);
} catch {
// A host-list failure must not start local probes for hosts whose
// configured origin may be remote.
localHostIds = [];
}
}
for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) {
const { timeoutMs, pauseAfterMs } = STATUS_RETRY_SCHEDULE[i];
@@ -108,6 +134,9 @@ export async function getAllServerStatuses(): Promise<
try {
const response = await statsApi.get("/status", {
timeout: timeoutMs,
...(localHostIds === null
? {}
: { params: { hostIds: localHostIds.join(",") } }),
// Silence per-attempt interceptor logging & health-monitor side
// effects on all attempts except the final one, so background
// blips don't look like real outages.
@@ -162,25 +191,24 @@ export async function getServerStatusById(id: number): Promise<ServerStatus> {
export async function getServerMetricsById(
id: number,
): Promise<ServerMetrics | null> {
try {
const response = await statsApi.get(`/metrics/${id}`, {
// Treat 404 as an expected "no metrics yet / disabled" signal rather
// than an error so we don't spam warn logs on the client.
validateStatus: (status) => status === 200 || status === 404,
});
if (response.status === 404) {
return null;
return metricsCache.get(String(id), async () => {
try {
const response = await statsApi.get(`/metrics/${id}`, {
// Treat 404 as an expected "no metrics yet / disabled" signal rather
// than an error so we don't spam warn logs on the client.
validateStatus: (status) => status === 200 || status === 404,
});
if (response.status === 404) return null;
return response.data;
} catch (error) {
// If a 404 still slips through (e.g. intercepted before reaching here),
// swallow it quietly; everything else still flows through handleApiError.
if (axios.isAxiosError(error) && error.response?.status === 404) {
return null;
}
handleApiError(error, "fetch server metrics");
}
return response.data;
} catch (error) {
// If a 404 still slips through (e.g. intercepted before reaching here),
// swallow it quietly; everything else still flows through handleApiError.
if (axios.isAxiosError(error) && error.response?.status === 404) {
return null;
}
handleApiError(error, "fetch server metrics");
throw error;
}
});
}
export async function startMetricsPolling(hostId: number): Promise<{
@@ -193,6 +221,7 @@ export async function startMetricsPolling(hostId: number): Promise<{
}> {
try {
const response = await statsApi.post(`/metrics/start/${hostId}`);
metricsCache.invalidate(String(hostId));
return response.data;
} catch (error: unknown) {
if (
@@ -246,6 +275,7 @@ export async function registerMetricsViewer(hostId: number): Promise<{
const response = await statsApi.post("/metrics/register-viewer", {
hostId,
});
metricsCache.invalidate(String(hostId));
return response.data;
} catch (error) {
handleApiError(error, "register metrics viewer");
@@ -280,6 +310,7 @@ export async function submitMetricsTOTP(
sessionId,
totpCode,
});
metricsCache.invalidate();
return response.data;
} catch (error) {
handleApiError(error, "submit metrics TOTP");
@@ -300,6 +331,7 @@ export async function notifyHostCreatedOrUpdated(
): Promise<void> {
try {
await statsApi.post("/host-updated", { hostId });
metricsCache.invalidate(String(hostId));
} catch (error) {
console.warn("Failed to notify stats server of host update:", error);
}
@@ -0,0 +1,29 @@
import { authApi, handleApiError } from "@/main-axios";
import {
sanitizeHostSidebarPreferences,
type HostSidebarPreferences,
} from "@/types/host-sidebar-preferences";
// HOST SIDEBAR PREFERENCES API
// ============================================================================
export async function getHostSidebarPreferences(): Promise<HostSidebarPreferences> {
try {
const response = await authApi.get("/host-sidebar/preferences");
return sanitizeHostSidebarPreferences(response.data?.preferences);
} catch (error) {
handleApiError(error, "fetch host sidebar preferences");
throw error;
}
}
export async function saveHostSidebarPreferences(
preferences: HostSidebarPreferences,
): Promise<void> {
try {
await authApi.put("/host-sidebar/preferences", preferences);
} catch (error) {
handleApiError(error, "save host sidebar preferences");
throw error;
}
}
+6 -1
View File
@@ -67,7 +67,7 @@ export async function deleteOpenTab(instanceId: string): Promise<void> {
export async function patchOpenTab(
instanceId: string,
updates: Partial<
Pick<OpenTabRecord, "label" | "tabOrder" | "backendSessionId">
Pick<OpenTabRecord, "hostId" | "label" | "tabOrder" | "backendSessionId">
>,
): Promise<void> {
await authApi.patch(`/open-tabs/${instanceId}`, updates);
@@ -112,10 +112,15 @@ export interface UserPreferences {
disableUpdateCheck?: boolean | null;
confirmTabClose?: boolean | null;
hiddenRailTabs?: string | null;
aiAssistantEnabled?: boolean | null;
aiReadOnlyCommands?: boolean | null;
compactHostView?: boolean | null;
statusColorScheme?: string | null;
customThemes?: string | null;
customKeybindings?: string | null;
terminalDefaults?: string | null;
rdpDefaults?: string | null;
terminalMacros?: string | null;
}
export function parseCustomThemes(raw?: string | null): SavedCustomTheme[] {
+93
View File
@@ -0,0 +1,93 @@
import axios from "axios";
import { handleApiError, statsApi } from "@/main-axios";
import type { ProxmoxStatsSnapshot } from "@/types/proxmox";
// Every function below is keyed by a host's numeric database id, and the
// receiving backend must own that host in its own database -- same caveat as
// host-metrics-api.ts / host-metrics-status-api.ts. All routes live under the
// `/proxmox-stats/*` prefix on the stats app (port 30005).
export async function getProxmoxStats(
hostId: number,
): Promise<ProxmoxStatsSnapshot | null> {
try {
const response = await statsApi.get(`/proxmox-stats/${hostId}`, {
// Treat 404 as an expected "no stats yet / disabled" signal rather than
// an error so we don't spam warn logs on the client.
validateStatus: (status) => status === 200 || status === 404,
});
if (response.status === 404) {
return null;
}
return response.data;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
return null;
}
handleApiError(error, "fetch proxmox stats");
throw error;
}
}
export async function startProxmoxStatsPolling(hostId: number): Promise<{
success: boolean;
viewerSessionId?: string;
status?: string;
error?: string;
}> {
try {
const response = await statsApi.post(`/proxmox-stats/start/${hostId}`);
return response.data;
} catch (error) {
handleApiError(error, "start proxmox stats polling");
throw error;
}
}
export async function stopProxmoxStatsPolling(
hostId: number,
viewerSessionId?: string,
): Promise<void> {
try {
await statsApi.post(`/proxmox-stats/stop/${hostId}`, { viewerSessionId });
} catch (error) {
handleApiError(error, "stop proxmox stats polling");
throw error;
}
}
export async function sendProxmoxStatsHeartbeat(
viewerSessionId: string,
): Promise<void> {
try {
await statsApi.post("/proxmox-stats/heartbeat", { viewerSessionId });
} catch (error) {
handleApiError(error, "send proxmox stats heartbeat");
throw error;
}
}
export interface ProxmoxStatsHistoryRow {
ts: string;
cpu_percent: number | null;
mem_percent: number | null;
disk_percent: number | null;
net_rx_bytes: number | null;
net_tx_bytes: number | null;
}
export interface ProxmoxStatsHistoryResponse {
rows: ProxmoxStatsHistoryRow[];
fromTs: string;
toTs: string;
}
export async function getProxmoxStatsHistory(
hostId: number,
opts: { range?: string; from?: string; to?: string },
): Promise<ProxmoxStatsHistoryResponse> {
const res = await statsApi.get(`/proxmox-stats/history/${hostId}`, {
params: opts,
});
return res.data as ProxmoxStatsHistoryResponse;
}
+42 -12
View File
@@ -1,10 +1,30 @@
import { handleApiError, rbacApi } from "@/main-axios";
import type { AccessRecord, Role, UserRole } from "@/main-axios";
import {
handleApiError,
rbacApi,
type AccessRecord,
type Role,
type UserRole,
} from "@/main-axios";
import type { AuthOverrideProtocol } from "@/types/auth-protocols";
import {
getConnectedRemoteApi,
resolveRemoteHostId,
} from "@/lib/remote-server-api";
async function getSharingTarget(hostId: number, syncId?: string | null) {
const api = await getConnectedRemoteApi();
if (!api || !syncId) return { api: rbacApi, hostId };
const remoteHostId = await resolveRemoteHostId(syncId);
if (remoteHostId === null) {
throw new Error("The synced host does not exist on the remote server");
}
return { api, hostId: remoteHostId };
}
export async function getRoles(): Promise<{ roles: Role[] }> {
try {
const response = await rbacApi.get("/rbac/roles");
const api = (await getConnectedRemoteApi()) ?? rbacApi;
const response = await api.get("/rbac/roles");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch roles");
@@ -104,6 +124,7 @@ export async function shareHost(
permissionLevel: SharePermissionLevel;
durationHours?: number;
},
syncId?: string | null,
): Promise<{
success: boolean;
expiresAt: string | null;
@@ -115,8 +136,9 @@ export async function shareHost(
}>;
}> {
try {
const response = await rbacApi.post(
`/rbac/host/${hostId}/share`,
const target = await getSharingTarget(hostId, syncId);
const response = await target.api.post(
`/rbac/host/${target.hostId}/share`,
shareData,
);
return response.data;
@@ -140,7 +162,8 @@ export async function shareFolder(
hostResults: Array<{ hostId: number; shared: boolean; reason?: string }>;
}> {
try {
const response = await rbacApi.post("/rbac/folder/share", {
const api = (await getConnectedRemoteApi()) ?? rbacApi;
const response = await api.post("/rbac/folder/share", {
folder,
...shareData,
});
@@ -157,10 +180,12 @@ export async function updateHostAccess(
permissionLevel?: SharePermissionLevel;
durationHours?: number | null;
},
syncId?: string | null,
): Promise<{ success: boolean; expiresAt: string | null }> {
try {
const response = await rbacApi.patch(
`/rbac/host/${hostId}/access/${accessId}`,
const target = await getSharingTarget(hostId, syncId);
const response = await target.api.patch(
`/rbac/host/${target.hostId}/access/${accessId}`,
update,
);
return response.data;
@@ -171,9 +196,11 @@ export async function updateHostAccess(
export async function getHostAccess(
hostId: number,
syncId?: string | null,
): Promise<{ accessList: AccessRecord[]; isOwner?: boolean }> {
try {
const response = await rbacApi.get(`/rbac/host/${hostId}/access`);
const target = await getSharingTarget(hostId, syncId);
const response = await target.api.get(`/rbac/host/${target.hostId}/access`);
return response.data;
} catch (error) {
throw handleApiError(error, "fetch host access");
@@ -212,7 +239,8 @@ export async function getSharedHosts(): Promise<{
}>;
}> {
try {
const response = await rbacApi.get("/rbac/shared-hosts");
const api = (await getConnectedRemoteApi()) ?? rbacApi;
const response = await api.get("/rbac/shared-hosts");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch shared hosts");
@@ -222,10 +250,12 @@ export async function getSharedHosts(): Promise<{
export async function revokeHostAccess(
hostId: number,
accessId: number,
syncId?: string | null,
): Promise<{ success: boolean }> {
try {
const response = await rbacApi.delete(
`/rbac/host/${hostId}/access/${accessId}`,
const target = await getSharingTarget(hostId, syncId);
const response = await target.api.delete(
`/rbac/host/${target.hostId}/access/${accessId}`,
);
return response.data;
} catch (error) {
-11
View File
@@ -152,17 +152,6 @@ export async function revokeSessionShare(
}
}
export async function endSessionShareSession(
shareId: string,
): Promise<{ success: true }> {
try {
const response = await authApi.post(`/session-sharing/${shareId}/end`);
return response.data;
} catch (error) {
throw handleApiError(error, "end shared session");
}
}
// ============================================================================
// GLOBAL ADMIN TOGGLE
// ============================================================================
+94
View File
@@ -1,3 +1,4 @@
import axios from "axios";
import { authApi, handleApiError, statsApi } from "@/main-axios";
// GLOBAL MONITORING SETTINGS
@@ -77,6 +78,7 @@ export async function updateSessionTimeout(
export async function getTailscaleSettings(): Promise<{
apiKey: string;
hasApiKey: boolean;
apiBaseUrl: string;
}> {
try {
const response = await authApi.get("/users/tailscale-settings");
@@ -88,10 +90,12 @@ export async function getTailscaleSettings(): Promise<{
export async function updateTailscaleSettings(
apiKey: string,
apiBaseUrl?: string,
): Promise<{ hasApiKey: boolean }> {
try {
const response = await authApi.patch("/users/tailscale-settings", {
apiKey,
apiBaseUrl,
});
return response.data;
} catch (error) {
@@ -109,12 +113,29 @@ export async function getTailscaleDevices(): Promise<{
lastSeen: string;
}>;
hasApiKey: boolean;
error?: string;
}> {
try {
const response = await authApi.get("/tailscale/devices");
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
const data = error.response?.data;
if (
data &&
typeof data === "object" &&
"hasApiKey" in data &&
typeof data.hasApiKey === "boolean"
) {
return data as {
devices: [];
hasApiKey: boolean;
error?: string;
};
}
}
handleApiError(error, "fetch Tailscale devices");
throw error;
}
}
@@ -174,6 +195,79 @@ export async function updateAnalyticsEnabled(
}
}
// ============================================================================
// TERMINAL IMAGE STORAGE SETTINGS
// ============================================================================
export type TerminalImageStorageMode = "auto" | "local" | "remote-sftp";
/** Public settings shape: the backend-internal localDir is never returned. */
export interface TerminalImageStorageSettings {
mode: TerminalImageStorageMode;
hostPath: string;
ttlMs: number;
maxCount: number;
maxBytes: number;
localMappingConfigured: boolean;
}
export interface TerminalImageStorageSettingsUpdate {
mode?: TerminalImageStorageMode;
localDir?: string;
hostPath?: string;
ttlMs?: number;
maxCount?: number;
maxBytes?: number;
}
export interface TerminalImageStorageTestResult {
mode: TerminalImageStorageMode;
connected: boolean;
remoteSftpAvailable: boolean;
localHostVisible: boolean | null;
selectedMode: "local" | "remote-sftp" | "unavailable";
localMappingConfigured: boolean;
}
export async function getTerminalImageStorageSettings(): Promise<TerminalImageStorageSettings> {
try {
const response = await authApi.get(
"/users/terminal-image-storage-settings",
);
return response.data;
} catch (error) {
handleApiError(error, "fetch terminal image storage settings");
}
}
export async function updateTerminalImageStorageSettings(
settings: TerminalImageStorageSettingsUpdate,
): Promise<TerminalImageStorageSettings> {
try {
const response = await authApi.patch(
"/users/terminal-image-storage-settings",
settings,
);
return response.data;
} catch (error) {
handleApiError(error, "update terminal image storage settings");
}
}
export async function testTerminalImageStorage(
instanceId: string,
): Promise<TerminalImageStorageTestResult> {
try {
const response = await authApi.post(
"/users/terminal-image-storage-settings/test",
{ instanceId },
);
return response.data;
} catch (error) {
handleApiError(error, "test terminal image storage");
}
}
// ============================================================================
// HOST DEFAULTS SETTINGS
// ============================================================================
+17 -1
View File
@@ -9,6 +9,9 @@ export interface NetworkTopologyNode {
tags?: string[];
parent?: string;
color?: string;
/** Absent on nodes; callers tell nodes from edges by testing these. */
source?: undefined;
target?: undefined;
};
position?: { x: number; y: number };
}
@@ -18,6 +21,8 @@ export interface NetworkTopologyEdge {
id?: string;
source: string;
target: string;
label?: undefined;
ip?: undefined;
};
}
@@ -26,7 +31,16 @@ export interface NetworkTopologyData {
edges: NetworkTopologyEdge[];
}
export async function getSnippets(): Promise<Record<string, unknown>> {
/**
* A snippet row as the list endpoint returns it. Callers that need the full
* shape narrow it themselves; only the id is relied on across the app.
*/
export interface SnippetRow {
id: number;
[key: string]: unknown;
}
export async function getSnippets(): Promise<SnippetRow[]> {
try {
const response = await authApi.get("/snippets");
return response.data;
@@ -72,11 +86,13 @@ export async function deleteSnippet(
export async function executeSnippet(
snippetId: number,
hostId: number,
inputValues?: Record<string, string>,
): Promise<{ success: boolean; output: string; error?: string }> {
try {
const response = await authApi.post("/snippets/execute", {
snippetId,
hostId,
...(inputValues ? { inputValues } : {}),
});
return response.data;
} catch (error) {
+46
View File
@@ -0,0 +1,46 @@
export interface ServerSentEvent {
event: string;
data: string;
}
export async function streamServerSentEvents(
url: string,
init: RequestInit,
onEvent: (event: ServerSentEvent) => void,
): Promise<void> {
const response = await fetch(url, init);
if (!response.ok) {
throw new Error(`SSE request failed with status ${response.status}`);
}
if (!response.body) throw new Error("SSE response has no body");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let eventName = "message";
let data: string[] = [];
const dispatch = () => {
if (data.length > 0) onEvent({ event: eventName, data: data.join("\n") });
eventName = "message";
data = [];
};
while (true) {
const { done, value } = await reader.read();
buffer += decoder.decode(value, { stream: !done });
const lines = buffer.split(/\r?\n/);
buffer = done ? "" : (lines.pop() ?? "");
for (const line of lines) {
if (line === "") dispatch();
else if (line.startsWith("event:")) eventName = line.slice(6).trimStart();
else if (line.startsWith("data:")) data.push(line.slice(5).trimStart());
}
if (done) {
dispatch();
return;
}
}
}
+191 -40
View File
@@ -1,4 +1,6 @@
import { getErrorMessage } from "../lib/error-message.js";
import axios from "axios";
import { asHttpError } from "@/lib/http-error";
import {
authApi,
fileManagerApi,
@@ -9,7 +11,13 @@ import {
} from "@/main-axios";
import { resolveConnectionOrigin } from "@/lib/connection-origin";
import { fileLogger } from "@/lib/frontend-logger";
import type { SSHHost } from "@/types/index";
import type { FileItem, SSHHost } from "@/types/index";
import { getCachedFileList } from "@/lib/file-list-request-cache";
import {
getCachedFileContent,
invalidateCachedFileContent,
type FileContentResult,
} from "@/lib/file-content-request-cache";
type ApiConnectionLog = {
type: "info" | "success" | "warning" | "error";
@@ -32,6 +40,22 @@ type ConnectErrorResponse = {
reason?: string;
};
/**
* The interactive-auth branches /ssh/connect can take before a session exists.
* Callers switch on these, so they cannot stay behind Record<string, unknown>.
*/
export interface SSHConnectResult {
success?: boolean;
status?: string;
reason?: "timeout" | "no_keyboard" | "auth_failed";
requires_totp?: boolean;
requires_warpgate?: boolean;
sessionId?: string;
prompt?: string;
url?: string;
securityKey?: string;
}
function buildFileManagerUrl(path: string): string {
const baseURL = String(fileManagerApi.defaults.baseURL || "");
return `${baseURL.replace(/\/$/, "")}${path}`;
@@ -60,6 +84,8 @@ export async function connectSSH(
sessionId: string,
config: {
hostId?: number;
/** Names the host across a sync pair; hostId only names it locally. */
syncId?: string | null;
ip: string;
port: number;
username: string;
@@ -78,7 +104,7 @@ export async function connectSSH(
socks5ProxyChain?: unknown;
jumpHosts?: Array<{ hostId: number }>;
},
): Promise<Record<string, unknown>> {
): Promise<SSHConnectResult> {
try {
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/connect",
@@ -278,17 +304,24 @@ export async function keepSSHAlive(
export async function listSSHFiles(
sessionId: string,
path: string,
): Promise<{ files: unknown[]; path: string }> {
try {
const response = await getFileManagerApiForSession(sessionId).get(
"/ssh/listFiles",
{ params: { sessionId, path } },
);
return response.data || { files: [], path };
} catch (error) {
handleApiError(error, "list SSH files");
return { files: [], path };
}
options: { force?: boolean } = {},
): Promise<{ files: FileItem[]; path: string }> {
return getCachedFileList(
sessionId,
path,
async () => {
try {
const response = await getFileManagerApiForSession(sessionId).get(
"/ssh/listFiles",
{ params: { sessionId, path } },
);
return response.data || { files: [], path };
} catch (error) {
handleApiError(error, "list SSH files");
}
},
options.force,
);
}
export async function identifySSHSymlink(
@@ -324,30 +357,41 @@ export async function resolveSSHPath(
export async function readSSHFile(
sessionId: string,
path: string,
): Promise<{
content: string;
path: string;
encoding?: "base64" | "utf8";
}> {
try {
const response = await getFileManagerApiForSession(sessionId).get(
"/ssh/readFile",
{ params: { sessionId, path } },
);
return response.data;
} catch (error: unknown) {
if (error.response?.status === 404) {
const customError = new Error("File not found");
(
customError as Error & { response?: unknown; isFileNotFound?: boolean }
).response = error.response;
(
customError as Error & { response?: unknown; isFileNotFound?: boolean }
).isFileNotFound = error.response.data?.fileNotFound || true;
throw customError;
}
handleApiError(error, "read SSH file");
}
options: { force?: boolean } = {},
): Promise<FileContentResult> {
return getCachedFileContent(
sessionId,
path,
async () => {
try {
const response = await getFileManagerApiForSession(sessionId).get(
"/ssh/readFile",
{ params: { sessionId, path } },
);
return response.data;
} catch (error: unknown) {
const httpError = asHttpError(error);
if (httpError.response?.status === 404) {
const customError = new Error("File not found");
(
customError as Error & {
response?: unknown;
isFileNotFound?: boolean;
}
).response = httpError.response;
(
customError as Error & {
response?: unknown;
isFileNotFound?: boolean;
}
).isFileNotFound = httpError.response.data?.fileNotFound || true;
throw customError;
}
handleApiError(error, "read SSH file");
}
},
options.force,
);
}
export async function writeSSHFile(
@@ -368,6 +412,7 @@ export async function writeSSHFile(
(response.data.message === "File written successfully" ||
response.status === 200)
) {
invalidateCachedFileContent(sessionId, path);
return response.data;
} else {
throw new Error("File write operation did not return success status");
@@ -472,12 +517,21 @@ export async function uploadSSHFile(
}
}
export interface DownloadedSSHFile {
/** base64-encoded file contents */
content: string;
fileName: string;
size: number;
mimeType: string;
path: string;
}
export async function downloadSSHFile(
sessionId: string,
filePath: string,
hostId?: number,
userId?: string,
): Promise<Record<string, unknown>> {
): Promise<DownloadedSSHFile> {
try {
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/downloadFile",
@@ -552,6 +606,7 @@ export async function deleteSSHItem(
isDirectory: boolean,
hostId?: number,
userId?: string,
permanent = false,
): Promise<Record<string, unknown>> {
try {
const response = await getFileManagerApiForSession(sessionId).delete(
@@ -563,15 +618,97 @@ export async function deleteSSHItem(
isDirectory,
hostId,
userId,
permanent,
},
},
);
if (!isDirectory) invalidateCachedFileContent(sessionId, path);
return response.data;
} catch (error) {
if (
axios.isAxiosError(error) &&
error.response?.data?.trashUnavailable === true
) {
throw error;
}
handleApiError(error, "delete SSH item");
}
}
export interface TrashItem {
id: string;
name: string;
originalPath: string;
isDirectory: boolean;
deletedAt: string;
size: number;
}
export async function getSSHTrash(sessionId: string): Promise<{
items: TrashItem[];
retentionDays: number;
canManageRetention: boolean;
}> {
try {
const response = await getFileManagerApiForSession(sessionId).get(
"/ssh/trash",
{ params: { sessionId } },
);
return response.data;
} catch (error) {
handleApiError(error, "list SSH trash");
}
}
export async function restoreSSHTrashItem(sessionId: string, id: string) {
try {
const response = await getFileManagerApiForSession(sessionId).post(
`/ssh/trash/${encodeURIComponent(id)}/restore`,
{ sessionId },
);
return response.data;
} catch (error) {
handleApiError(error, "restore SSH trash item");
}
}
export async function permanentlyDeleteSSHTrashItem(
sessionId: string,
id: string,
) {
try {
await getFileManagerApiForSession(sessionId).delete(
`/ssh/trash/${encodeURIComponent(id)}`,
{ data: { sessionId } },
);
} catch (error) {
handleApiError(error, "permanently delete SSH trash item");
}
}
export async function emptySSHTrash(sessionId: string) {
try {
await getFileManagerApiForSession(sessionId).delete("/ssh/trash", {
data: { sessionId },
});
} catch (error) {
handleApiError(error, "empty SSH trash");
}
}
export async function updateSSHTrashRetention(
sessionId: string,
retentionDays: number,
) {
try {
await getFileManagerApiForSession(sessionId).put("/ssh/trash-retention", {
retentionDays,
});
} catch (error) {
handleApiError(error, "update SSH trash retention");
}
}
export async function setSudoPassword(
sessionId: string,
password: string,
@@ -586,13 +723,20 @@ export async function setSudoPassword(
}
}
export interface CopySSHItemResult {
message?: string;
/** Set when the copy was renamed to avoid clobbering an existing entry. */
uniqueName?: string;
targetPath?: string;
}
export async function copySSHItem(
sessionId: string,
sourcePath: string,
targetDir: string,
hostId?: number,
userId?: string,
): Promise<Record<string, unknown>> {
): Promise<CopySSHItemResult> {
try {
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/copyItem",
@@ -626,6 +770,10 @@ export async function renameSSHItem(
"/ssh/renameItem",
{ sessionId, oldPath, newName, hostId, userId },
);
invalidateCachedFileContent(sessionId, oldPath);
const separator = oldPath.lastIndexOf("/");
const newPath = `${oldPath.slice(0, separator + 1)}${newName}`;
invalidateCachedFileContent(sessionId, newPath);
return response.data;
} catch (error) {
handleApiError(error, "rename SSH item");
@@ -654,6 +802,8 @@ export async function moveSSHItem(
timeout: 60000,
},
);
invalidateCachedFileContent(sessionId, oldPath);
invalidateCachedFileContent(sessionId, newPath);
return response.data;
} catch (error) {
handleApiError(error, "move SSH item");
@@ -830,6 +980,7 @@ export async function ensureSSHSessionForHost(
try {
const result = await connectSSH(sessionId, {
hostId: host.id,
syncId: host.syncId ?? null,
ip: host.ip,
port: host.port,
username: host.username,
@@ -859,7 +1010,7 @@ export async function ensureSSHSessionForHost(
return { state: "ready", sessionId };
} catch (err) {
const message = err instanceof Error ? err.message : "Connection failed";
const message = getErrorMessage(err, "Connection failed");
return { state: "error", error: message };
}
}
+38 -1
View File
@@ -12,6 +12,11 @@ import {
getCachedSSHHosts,
invalidateHostsAndStatusCaches,
} from "@/lib/hosts-request-cache";
import { requestRemoteSync } from "@/lib/remote-sync-trigger";
import {
getConnectedRemoteApi,
markRemoteSharedHosts,
} from "@/lib/remote-server-api";
// SSH HOST MANAGEMENT
// ============================================================================
@@ -23,7 +28,22 @@ export type GetSSHHostsOptions = {
async function loadSSHHostsFromApi(): Promise<SSHHost[]> {
const hostsResponse = await sshHostApi.get("/db/host");
return Array.isArray(hostsResponse.data) ? hostsResponse.data : [];
const localHosts = Array.isArray(hostsResponse.data)
? hostsResponse.data
: [];
const remoteApi = await getConnectedRemoteApi();
if (!remoteApi) return localHosts;
try {
const remoteResponse = await remoteApi.get("/host/db/host");
const remoteSharedHosts = Array.isArray(remoteResponse.data)
? markRemoteSharedHosts(remoteResponse.data)
: [];
return [...localHosts, ...remoteSharedHosts];
} catch {
// Keep the last locally synced host set usable while the server is offline.
return localHosts;
}
}
export async function getSSHHosts(
@@ -68,10 +88,12 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
headers: { "Content-Type": "multipart/form-data" },
});
invalidateHostsAndStatusCaches();
void requestRemoteSync();
return response.data;
}
const response = await sshHostApi.post("/db/host", hostData);
invalidateHostsAndStatusCaches();
void requestRemoteSync();
return response.data;
} catch (error) {
throw handleApiError(error, "create SSH host");
@@ -92,10 +114,12 @@ export async function updateSSHHost(
headers: { "Content-Type": "multipart/form-data" },
});
invalidateHostsAndStatusCaches();
void requestRemoteSync();
return response.data;
}
const response = await sshHostApi.put(`/db/host/${hostId}`, hostData);
invalidateHostsAndStatusCaches();
void requestRemoteSync();
return response.data;
} catch (error) {
throw handleApiError(error, "update SSH host");
@@ -259,12 +283,25 @@ export async function bulkUpdateSSHHosts(
}
}
export async function reorderSSHHosts(
positions: { id: number; sortOrder: number }[],
): Promise<{ updated: number }> {
try {
const response = await sshHostApi.put("/reorder", { positions });
invalidateHostsAndStatusCaches();
return response.data;
} catch (error) {
handleApiError(error, "reorder SSH hosts");
}
}
export async function deleteSSHHost(
hostId: number,
): Promise<Record<string, unknown>> {
try {
const response = await sshHostApi.delete(`/db/host/${hostId}`);
invalidateHostsAndStatusCaches();
void requestRemoteSync();
return response.data;
} catch (error) {
handleApiError(error, "delete SSH host");
+65 -7
View File
@@ -1,6 +1,11 @@
import { AxiosError } from "axios";
import { authApi, handleApiError, markUserAuthenticated } from "@/main-axios";
import type { AuthResponse } from "@/main-axios";
import type { TermixAlert } from "@/types";
import {
authApi,
handleApiError,
markUserAuthenticated,
type AuthResponse,
} from "@/main-axios";
// ALERTS
// ============================================================================
@@ -86,7 +91,7 @@ export async function generateBackupCodes(
}
export async function getUserAlerts(): Promise<{
alerts: Array<Record<string, unknown>>;
alerts: TermixAlert[];
}> {
try {
const response = await authApi.get(`/alerts`);
@@ -113,9 +118,34 @@ export async function dismissAlert(
// UPDATES & RELEASES
// ============================================================================
export interface ReleaseItem {
id: number;
title: string;
description: string;
link: string;
pubDate: string;
version: string;
isPrerelease: boolean;
isDraft: boolean;
assets: Array<{
name: string;
size: number;
download_count: number;
download_url: string;
}>;
}
export interface ReleasesRSSResponse {
feed: { title: string; description: string; link: string; updated: string };
items: ReleaseItem[];
total_count: number;
cached: boolean;
cache_age?: number;
}
export async function getReleasesRSS(
perPage: number = 100,
): Promise<Record<string, unknown>> {
): Promise<ReleasesRSSResponse> {
try {
const response = await authApi.get(`/releases/rss?per_page=${perPage}`);
return response.data;
@@ -124,9 +154,37 @@ export async function getReleasesRSS(
}
}
export async function getVersionInfo(
checkRemote = true,
): Promise<Record<string, unknown>> {
export interface VersionInfo {
status?: "up_to_date" | "requires_update" | "beta";
/** Same value as remoteVersion; the endpoint sends both. */
version?: string;
localVersion?: string;
remoteVersion?: string;
latest_release?: {
tag_name?: string;
name?: string;
published_at?: string;
html_url?: string;
body?: string;
};
cached?: boolean;
cache_age?: number;
// Callers reach for fields beyond the ones above -- SystemOverviewWidget
// reads `updateAvailable`, which this endpoint does not in fact return --
// so keep the index signature the previous `Record<string, unknown>` gave
// them. Typing those reads out of existence is a separate change.
[key: string]: unknown;
}
// Where the release page lives inside a version response. Both surfaces that
// render a version badge read it, and an empty string is what they treat as
// "no link to offer", so keep the shape in one place rather than repeating the
// optional chain at each call site.
export function releaseUrlFrom(info: VersionInfo | null | undefined): string {
return info?.latest_release?.html_url ?? "";
}
export async function getVersionInfo(checkRemote = true): Promise<VersionInfo> {
try {
const response = await authApi.get(
`/version${checkRemote ? "" : "?checkRemote=false"}`,
+28
View File
@@ -0,0 +1,28 @@
import { authApi, handleApiError } from "@/main-axios";
import {
normalizeTouchInputSettings,
type TouchInputSettings,
} from "@/types/touch-input-settings";
export async function getTouchInputSettings(): Promise<TouchInputSettings> {
try {
const response = await authApi.get("/users/touch-input-settings");
return normalizeTouchInputSettings(response.data);
} catch (error) {
handleApiError(error, "fetch touch input settings");
}
}
export async function updateTouchInputSettings(
settings: TouchInputSettings,
): Promise<TouchInputSettings> {
try {
const response = await authApi.patch(
"/users/touch-input-settings",
settings,
);
return normalizeTouchInputSettings(response.data);
} catch (error) {
handleApiError(error, "update touch input settings");
}
}
+71 -27
View File
@@ -12,6 +12,8 @@ import type {
TunnelConnection,
TunnelStatus,
} from "@/types/index";
import { streamServerSentEvents } from "./sse-stream";
import { runAdaptivePolling } from "@/lib/adaptive-polling";
// TUNNEL MANAGEMENT
// ============================================================================
@@ -65,51 +67,93 @@ export function subscribeTunnelStatuses(
onError?: () => void,
): () => void {
const baseURL = (tunnelApi.defaults.baseURL || "").replace(/\/$/, "");
const source = new EventSource(`${baseURL}/tunnel/status/stream`, {
withCredentials: true,
});
const controller = new AbortController();
let latestLocal: Record<string, TunnelStatus> = {};
let latestRemote: Record<string, TunnelStatus> = {};
let remotePollTimer: ReturnType<typeof setInterval> | null = null;
let stopRemotePolling: (() => void) | null = null;
const emitMerged = () => {
onStatuses({ ...latestLocal, ...latestRemote });
};
source.addEventListener("statuses", (event) => {
try {
latestLocal = JSON.parse(event.data) as Record<string, TunnelStatus>;
emitMerged();
} catch {
onError?.();
}
});
const waitToReconnect = () =>
new Promise<void>((resolve) => {
const onAbort = () => {
clearTimeout(timeout);
resolve();
};
const timeout = setTimeout(() => {
controller.signal.removeEventListener("abort", onAbort);
resolve();
}, 1000);
controller.signal.addEventListener("abort", onAbort, { once: true });
});
source.onerror = () => {
onError?.();
};
void (async () => {
while (!controller.signal.aborted) {
const headers = new Headers({ Accept: "text/event-stream" });
if (isElectron()) {
headers.set("X-Electron-App", "true");
const jwt = localStorage.getItem("jwt");
if (jwt) headers.set("Authorization", `Bearer ${jwt}`);
}
try {
await streamServerSentEvents(
`${baseURL}/tunnel/status/stream`,
{ credentials: "include", headers, signal: controller.signal },
(event) => {
if (event.event !== "statuses") return;
try {
latestLocal = JSON.parse(event.data) as Record<
string,
TunnelStatus
>;
emitMerged();
} catch {
onError?.();
}
},
);
if (!controller.signal.aborted) onError?.();
} catch (error) {
const aborted =
controller.signal.aborted ||
(error instanceof DOMException && error.name === "AbortError");
if (!aborted) onError?.();
}
if (!controller.signal.aborted) await waitToReconnect();
}
})();
// Remote tunnel status has no SSE stream exposed to the desktop app yet,
// so poll it at a modest interval when a remote server is connected.
isRemoteSyncConnected().then((connected) => {
if (!connected) return;
const pollRemote = async () => {
try {
if (!connected || controller.signal.aborted) return;
let signature = "";
stopRemotePolling = runAdaptivePolling(
async () => {
const result = await getRemoteTunnelApi().get("/tunnel/status");
latestRemote = result.data || {};
const next = result.data || {};
const nextSignature = JSON.stringify(next);
const changed = nextSignature !== signature;
signature = nextSignature;
latestRemote = next;
emitMerged();
} catch {
// remote unreachable this tick -- keep last known remote statuses
}
};
pollRemote();
remotePollTimer = setInterval(pollRemote, 5000);
return changed;
},
{
minIntervalMs: 5000,
maxIntervalMs: 30000,
stablePollsPerStep: 3,
},
{ enabled: () => !controller.signal.aborted },
);
});
return () => {
source.close();
if (remotePollTimer) clearInterval(remotePollTimer);
controller.abort();
stopRemotePolling?.();
};
}
+34
View File
@@ -0,0 +1,34 @@
import { authApi, handleApiError } from "@/main-axios";
import {
sanitizeUiPreferences,
type UiPreferences,
} from "@/types/ui-preferences";
// UI PREFERENCES API
// ============================================================================
export async function getUiPreferences(): Promise<UiPreferences> {
try {
const response = await authApi.get("/ui-preferences");
return sanitizeUiPreferences(response.data?.preferences);
} catch (error) {
handleApiError(error, "fetch UI preferences");
throw error;
}
}
/**
* Sends a partial document. The backend merges overrides two levels deep, so
* only changed keys need to be sent; a null clears an override and hands the
* knob back to the preset.
*/
export async function saveUiPreferences(
preferences: Partial<UiPreferences> & Record<string, unknown>,
): Promise<void> {
try {
await authApi.put("/ui-preferences", preferences);
} catch (error) {
handleApiError(error, "save UI preferences");
throw error;
}
}
+21 -4
View File
@@ -1,12 +1,29 @@
import { authApi, handleApiError } from "@/main-axios";
import type { UserInfo } from "@/main-axios";
import { authApi, handleApiError, type UserInfo } from "@/main-axios";
import { getConnectedRemoteApi } from "@/lib/remote-server-api";
// USER MANAGEMENT
// ============================================================================
export async function getUserList(): Promise<{ users: UserInfo[] }> {
export type UserListOptions = {
/** Case-insensitive username substring filter. */
search?: string;
/** Page size. Omit to fetch every user (what the share pickers want). */
limit?: number;
offset?: number;
};
export async function getUserList(
options: UserListOptions = {},
): Promise<{ users: UserInfo[]; total?: number }> {
try {
const response = await authApi.get("/users/list");
const api = (await getConnectedRemoteApi()) ?? authApi;
const response = await api.get("/users/list", {
params: {
...(options.search ? { search: options.search } : {}),
...(options.limit ? { limit: options.limit } : {}),
...(options.offset ? { offset: options.offset } : {}),
},
});
return response.data;
} catch (error) {
handleApiError(error, "fetch user list");
+2 -47
View File
@@ -1,14 +1,9 @@
import type {
AuthenticationResponseJSON,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
RegistrationResponseJSON,
} from "@simplewebauthn/browser";
import {
startAuthentication,
startRegistration,
} from "@simplewebauthn/browser";
import { authApi, handleApiError, type AuthResponse } from "@/main-axios";
import { startRegistration } from "@simplewebauthn/browser";
import { authApi, handleApiError } from "@/main-axios";
export type WebAuthnUserVerification = "discouraged" | "preferred" | "required";
@@ -28,11 +23,6 @@ type RegistrationOptionsResponse = {
challengeId: string;
};
type AuthenticationOptionsResponse = {
options: PublicKeyCredentialRequestOptionsJSON;
challengeId: string;
};
export async function listWebAuthnCredentials(): Promise<{
credentials: WebAuthnCredentialSummary[];
}> {
@@ -70,41 +60,6 @@ export async function registerWebAuthnCredential(
}
}
export async function authenticateWithWebAuthn(
username: string,
rememberMe: boolean,
userVerification: WebAuthnUserVerification = "preferred",
): Promise<AuthResponse> {
try {
const optionsResponse = await authApi.post<AuthenticationOptionsResponse>(
"/users/webauthn/authenticate/options",
{
username: username.trim() || undefined,
userVerification,
},
);
const credential = await startAuthentication({
optionsJSON: optionsResponse.data.options,
});
const verifyResponse = await authApi.post<AuthResponse>(
"/users/webauthn/authenticate/verify",
{
challengeId: optionsResponse.data.challengeId,
rememberMe,
response: credential as AuthenticationResponseJSON,
},
);
if (verifyResponse.data.token) {
localStorage.setItem("jwt", verifyResponse.data.token);
}
return verifyResponse.data;
} catch (error) {
throw handleApiError(error, "authenticate with passkey");
}
}
export async function deleteWebAuthnCredential(
credentialId: string,
): Promise<{ success: boolean }> {
+125
View File
@@ -0,0 +1,125 @@
import { authApi, handleApiError } from "@/main-axios";
import type { Workspace, WorkspacePayload } from "@/types/ui-types";
export async function listWorkspaces(): Promise<Workspace[]> {
try {
const response = await authApi.get("/workspaces");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch workspaces");
}
}
export async function createWorkspace(data: {
name: string;
color?: string | null;
icon?: string | null;
payload: WorkspacePayload;
}): Promise<Workspace> {
try {
const response = await authApi.post("/workspaces", data);
return response.data;
} catch (error) {
throw handleApiError(error, "create workspace");
}
}
export async function renameWorkspace(
id: number,
data: { name?: string; color?: string | null; icon?: string | null },
): Promise<Workspace> {
try {
const response = await authApi.patch(`/workspaces/${id}`, data);
return response.data;
} catch (error) {
throw handleApiError(error, "update workspace");
}
}
export async function updateWorkspaceContent(
id: number,
payload: WorkspacePayload,
): Promise<Workspace> {
try {
const response = await authApi.put(`/workspaces/${id}/content`, {
payload,
});
return response.data;
} catch (error) {
throw handleApiError(error, "update workspace content");
}
}
export async function deleteWorkspace(
id: number,
): Promise<{ success: boolean }> {
try {
const response = await authApi.delete(`/workspaces/${id}`);
return response.data;
} catch (error) {
throw handleApiError(error, "delete workspace");
}
}
export async function duplicateWorkspace(
id: number,
name: string,
): Promise<Workspace> {
try {
const response = await authApi.post(`/workspaces/${id}/duplicate`, {
name,
});
return response.data;
} catch (error) {
throw handleApiError(error, "duplicate workspace");
}
}
export async function setDefaultWorkspace(id: number): Promise<Workspace> {
try {
const response = await authApi.post(`/workspaces/${id}/set-default`);
return response.data;
} catch (error) {
throw handleApiError(error, "set default workspace");
}
}
export async function unsetDefaultWorkspace(id: number): Promise<Workspace> {
try {
const response = await authApi.post(`/workspaces/${id}/unset-default`);
return response.data;
} catch (error) {
throw handleApiError(error, "unset default workspace");
}
}
export async function applyWorkspaceServer(id: number): Promise<Workspace> {
try {
const response = await authApi.post(`/workspaces/${id}/apply`);
return response.data;
} catch (error) {
throw handleApiError(error, "apply workspace");
}
}
export async function getLastSessionWorkspace(): Promise<Workspace | null> {
try {
const response = await authApi.get("/workspaces/last-session");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch last session workspace");
}
}
export async function saveLastSessionWorkspace(
payload: WorkspacePayload,
): Promise<Workspace> {
try {
const response = await authApi.put("/workspaces/last-session", {
payload,
});
return response.data;
} catch (error) {
throw handleApiError(error, "save last session workspace");
}
}
+62 -10
View File
@@ -33,6 +33,7 @@ import {
getCurrentToken,
getOidcSilentLoginDefault,
requestDesktopAutoSession,
requestTrustedProxyLogin,
} from "@/main-axios";
import { getSSOProviders, ldapLogin } from "@/api/sso-provider-api";
import type { SSOProviderPublic } from "@/types/index";
@@ -258,6 +259,7 @@ export function Auth({ onLogin }: AuthProps) {
const [ldapUsername, setLdapUsername] = useState("");
const [ldapPassword, setLdapPassword] = useState("");
const silentSigninHandledRef = useRef(false);
const proxySigninHandledRef = useRef(false);
const [oidcSilentLoginDefault, setOidcSilentLoginDefault] = useState(false);
const [oidcSilentLoginDefaultLoaded, setOidcSilentLoginDefaultLoaded] =
useState(false);
@@ -281,6 +283,25 @@ export function Auth({ onLogin }: AuthProps) {
>(!isElectron() || isInElectronWebView() ? true : null);
const [desktopAutoSessionRetries, setDesktopAutoSessionRetries] = useState(0);
useEffect(() => {
if (proxySigninHandledRef.current || isElectron()) return;
proxySigninHandledRef.current = true;
requestTrustedProxyLogin()
.then((result) => {
if (!result.enabled || !result.success) return;
storeAuth(result.username || "");
onLogin(
result.username || "",
result.userId || undefined,
!!result.is_admin,
);
})
.catch(() => {
// Leave the login screen visible. The backend logs the reason without
// exposing trusted proxy configuration to an untrusted client.
});
}, [onLogin]);
useEffect(() => {
try {
localStorage.setItem("rememberMe", rememberMe.toString());
@@ -336,17 +357,48 @@ export function Auth({ onLogin }: AuthProps) {
// Electron). Waiting avoids flashing a login screen the user is about
// to skip past via auto-login.
if (desktopAutoSessionDone !== true) return;
let cancelled = false;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
// Right after a server update/restart (or behind a reverse proxy that's
// still warming up), the very first request can transiently fail even
// though the backend/database is fine seconds later. A single failure
// here used to permanently show the "could not connect to the database"
// screen, forcing users to manually reload -- sometimes repeatedly.
// Retry a few times with backoff before treating it as a real failure.
const maxAttempts = 5;
const attempt = (attemptNumber: number) => {
getSetupRequired()
.then((res) => {
if (cancelled) return;
if (res.setup_required) {
setFirstUser(true);
setView("register");
}
setDbConnectionFailed(false);
setDbHealthChecking(false);
})
.catch(() => {
if (cancelled) return;
if (attemptNumber >= maxAttempts) {
setDbConnectionFailed(true);
setDbHealthChecking(false);
return;
}
const delay = Math.min(500 * 2 ** attemptNumber, 5000);
retryTimer = setTimeout(() => {
if (!cancelled) attempt(attemptNumber + 1);
}, delay);
});
};
setDbHealthChecking(true);
getSetupRequired()
.then((res) => {
if (res.setup_required) {
setFirstUser(true);
setView("register");
}
setDbConnectionFailed(false);
})
.catch(() => setDbConnectionFailed(true))
.finally(() => setDbHealthChecking(false));
attempt(0);
return () => {
cancelled = true;
if (retryTimer) clearTimeout(retryTimer);
};
}, [desktopAutoSessionDone]);
// A cold first launch spawns the embedded backend as a separate process
+30 -5
View File
@@ -1,3 +1,4 @@
import { getErrorMessage } from "../lib/error-message.js";
import React, { useState, useEffect, useRef, useCallback } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/alert.tsx";
import { useTranslation } from "react-i18next";
@@ -15,6 +16,12 @@ interface ElectronLoginFormProps {
targetPurpose?: "local" | "remoteSync";
}
interface SaveRemoteSyncJwtResult {
success: boolean;
reason?: string;
error?: string;
}
const AUTH_MESSAGE_SOURCES = new Set([
"auth_component",
"totp_auth_component",
@@ -51,14 +58,33 @@ export function ElectronLoginForm({
try {
if (token) {
if (targetPurpose === "remoteSync") {
await window.electronAPI?.invoke?.("save-remote-sync-jwt", token);
// The main process refuses to persist the JWT when it has no OS
// keyring to encrypt it with, and reports that by resolving with
// success: false. Dropping the result signs the user in against a
// store that kept nothing, so the next sync tick calls a session
// that was never saved expired.
const result = (await window.electronAPI?.invoke?.(
"save-remote-sync-jwt",
token,
)) as SaveRemoteSyncJwtResult | undefined;
if (!result?.success) {
throw new Error(
result?.reason === "encryption_unavailable"
? t("errors.keyringUnavailable")
: result?.error || t("errors.authTokenSaveFailed"),
);
}
} else {
localStorage.setItem("jwt", token);
}
}
await onAuthSuccessRef.current(token);
} catch {
setError(t("errors.authTokenSaveFailed"));
} catch (err) {
setError(
err instanceof Error && err.message
? err.message
: t("errors.authTokenSaveFailed"),
);
isAuthenticatingRef.current = false;
setIsAuthenticating(false);
hasAuthenticatedRef.current = false;
@@ -151,8 +177,7 @@ export function ElectronLoginForm({
typeof event.data.providerId === "number"
? event.data.providerId
: undefined;
const error =
err instanceof Error ? err.message : t("errors.failedOidcLogin");
const error = getErrorMessage(err, t("errors.failedOidcLogin"));
iframeRef.current?.contentWindow?.postMessage(
{
type: "OIDC_SYSTEM_BROWSER_AUTH_RESULT",
@@ -0,0 +1,104 @@
import { useState, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/dialog";
import {
extractSnippetInputs,
resolveSnippetContent,
type SnippetHostContext,
} from "@/lib/snippet-variables";
import type { Snippet } from "@/types/ui-types";
/**
* Shown before running a snippet that contains $INPUT_n placeholders --
* collects a value per placeholder and previews the fully resolved command
* (host vars + inputs) before handing the result back to the caller.
*/
export function SnippetVariablesDialog({
snippet,
host,
onCancel,
onConfirm,
}: {
snippet: Snippet;
host: SnippetHostContext | null;
onCancel: () => void;
onConfirm: (
resolvedContent: string,
inputValues: Record<string, string>,
) => void;
}) {
const { t } = useTranslation();
const inputs = useMemo(
() => extractSnippetInputs(snippet.content),
[snippet.content],
);
const [values, setValues] = useState<Record<string, string>>({});
useEffect(() => {
setValues({});
}, [snippet]);
const preview = resolveSnippetContent(snippet.content, host, values);
return (
<Dialog open onOpenChange={(v) => !v && onCancel()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t("newUi.sidebar.snippets.variablesDialogTitle", {
name: snippet.name,
})}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("newUi.sidebar.snippets.variablesDialogDescription")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 mt-1">
{inputs.map((input) => (
<div key={input.key} className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">{input.label}</label>
<Input
autoFocus={inputs[0]?.key === input.key}
value={values[input.key] ?? ""}
onChange={(e) =>
setValues((prev) => ({
...prev,
[input.key]: e.target.value,
}))
}
/>
</div>
))}
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold text-muted-foreground">
{t("newUi.sidebar.snippets.variablesPreviewLabel")}
</label>
<span className="text-xs text-muted-foreground font-mono px-2.5 py-2 border border-border bg-muted/20 min-w-0 break-all whitespace-pre-wrap">
{preview}
</span>
</div>
</div>
<div className="flex items-center justify-end gap-2 mt-2">
<Button variant="ghost" onClick={onCancel}>
{t("newUi.sidebar.snippets.cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={() => onConfirm(preview, values)}
>
{t("newUi.sidebar.snippets.variablesConfirmButton")}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
-64
View File
@@ -1,64 +0,0 @@
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDownIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
);
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
+1 -1
View File
@@ -52,7 +52,7 @@ function AlertDialogContent({
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-none border border-border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
+1 -1
View File
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
"relative w-full border border-border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
+2 -2
View File
@@ -6,7 +6,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
"inline-flex items-center justify-center rounded-none border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
@@ -17,7 +17,7 @@ const badgeVariants = cva(
destructive:
"border-transparent bg-destructive text-foreground [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
-63
View File
@@ -1,63 +0,0 @@
import {
Children,
type ReactElement,
cloneElement,
isValidElement,
} from "react";
import { type ButtonProps } from "@/components/button";
import { cn } from "@/lib/utils";
interface ButtonGroupProps {
className?: string;
orientation?: "horizontal" | "vertical";
children: ReactElement<ButtonProps>[] | React.ReactNode;
}
export const ButtonGroup = ({
className,
orientation = "horizontal",
children,
}: ButtonGroupProps) => {
const isHorizontal = orientation === "horizontal";
const isVertical = orientation === "vertical";
// Normalize and filter only valid React elements
const childArray = Children.toArray(children).filter(
(child): child is ReactElement<ButtonProps> => isValidElement(child),
);
const totalButtons = childArray.length;
return (
<div
className={cn(
"flex",
{
"flex-col": isVertical,
"w-fit": isVertical,
},
className,
)}
>
{childArray.map((child, index) => {
const isFirst = index === 0;
const isLast = index === totalButtons - 1;
return cloneElement(child, {
className: cn(
{
"rounded-l-none": isHorizontal && !isFirst,
"rounded-r-none": isHorizontal && !isLast,
"border-l-0": isHorizontal && !isFirst,
"rounded-t-none": isVertical && !isFirst,
"rounded-b-none": isVertical && !isLast,
"border-t-0": isVertical && !isFirst,
},
child.props.className,
),
});
})}
</div>
);
};
+1 -1
View File
@@ -12,7 +12,7 @@ function Checkbox({
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-none border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
+36 -35
View File
@@ -1,12 +1,13 @@
import * as React from "react";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import { cn } from "@/lib/utils";
const Command = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<div
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-none bg-popover text-popover-foreground",
@@ -18,15 +19,12 @@ const Command = React.forwardRef<
Command.displayName = "Command";
const CommandInput = React.forwardRef<
HTMLInputElement,
React.InputHTMLAttributes<HTMLInputElement>
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div
className="flex items-center border-b border-border px-3"
cm-input-wrapper=""
>
<div className="flex items-center border-b border-border px-3">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<input
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-none bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
@@ -39,52 +37,55 @@ const CommandInput = React.forwardRef<
CommandInput.displayName = "CommandInput";
const CommandList = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<div
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
className={cn(
"max-h-[300px] overflow-y-auto overflow-x-hidden [&_[cmdk-list-sizer]]:flex [&_[cmdk-list-sizer]]:flex-col",
className,
)}
{...props}
/>
));
CommandList.displayName = "CommandList";
const CommandGroup = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & { heading?: string }
>(({ className, heading, children, ...props }, ref) => (
<div
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cm-group-heading]]:px-2 [&_[cm-group-heading]]:py-1.5 [&_[cm-group-heading]]:text-xs [&_[cm-group-heading]]:font-medium [&_[cm-group-heading]]:text-muted-foreground",
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className,
)}
{...props}
>
{heading && <div cm-group-heading="">{heading}</div>}
{children}
</div>
/>
));
CommandGroup.displayName = "CommandGroup";
const CommandSeparator = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("-mx-1 h-px bg-border", className)} {...props} />
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
));
CommandSeparator.displayName = "CommandSeparator";
const CommandItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & { onSelect?: () => void }
>(({ className, onSelect, ...props }, ref) => (
<div
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
onClick={onSelect}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-muted hover:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 transition-colors",
"relative flex cursor-default select-none items-center rounded-none px-2 py-1.5 text-sm outline-none data-[selected=true]:bg-muted data-[selected=true]:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 transition-colors",
className,
)}
{...props}
@@ -93,10 +94,10 @@ const CommandItem = React.forwardRef<
CommandItem.displayName = "CommandItem";
const CommandEmpty = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>(({ className, ...props }, ref) => (
<div
<CommandPrimitive.Empty
ref={ref}
className={cn("py-6 text-center text-sm text-muted-foreground", className)}
{...props}
@@ -0,0 +1,198 @@
import React, { useEffect, useRef, useState } from "react";
import { useConnectionLog } from "@/ssh/connection-log/ConnectionLogContext.tsx";
import { useTranslation } from "react-i18next";
import { copyToClipboard } from "@/lib/clipboard.ts";
import { Button } from "@/components/button.tsx";
import { cn } from "@/lib/utils.ts";
import {
ChevronDown,
ChevronUp,
Copy,
Info,
CheckCircle2,
AlertTriangle,
XCircle,
} from "lucide-react";
import { toast } from "sonner";
interface ConnectionLogPanelProps {
isConnecting: boolean;
isConnected: boolean;
hasConnectionError: boolean;
position?: "top" | "bottom";
className?: string;
}
const COLLAPSED_HEIGHT = "h-[140px]";
const EXPANDED_HEIGHT = "h-[45%] min-h-[240px]";
export function ConnectionLogPanel({
isConnecting,
isConnected,
hasConnectionError,
position = "bottom",
className,
}: ConnectionLogPanelProps) {
const { t } = useTranslation();
const { logs, clearLogs, isExpanded, toggleExpanded, setIsExpanded } =
useConnectionLog();
const lastLogRef = useRef<HTMLDivElement>(null);
const [manuallyCollapsed, setManuallyCollapsed] = useState(false);
useEffect(() => {
if (hasConnectionError) {
setManuallyCollapsed(false);
setIsExpanded(true);
}
}, [hasConnectionError, setIsExpanded]);
useEffect(() => {
if (isConnected && !hasConnectionError && !isConnecting) {
clearLogs();
setManuallyCollapsed(false);
}
}, [isConnected, hasConnectionError, isConnecting, clearLogs]);
useEffect(() => {
if (lastLogRef.current) {
lastLogRef.current.scrollIntoView({ block: "end" });
}
}, [logs]);
const shouldShow =
!isConnected && (isConnecting || hasConnectionError || logs.length > 0);
if (!shouldShow) {
return null;
}
const expanded = isExpanded && !manuallyCollapsed;
const handleToggle = () => {
if (hasConnectionError) {
setManuallyCollapsed((prev) => !prev);
return;
}
toggleExpanded();
};
const copyLogsToClipboard = async () => {
const logsText = logs
.map((log) => {
const time = log.timestamp.toLocaleTimeString();
return `[${time}] [${log.type.toUpperCase()}] ${log.message}`;
})
.join("\n");
const ok = await copyToClipboard(logsText);
if (ok) toast.success(t("terminal.connectionLogCopied"));
else toast.error(t("terminal.connectionLogCopyFailed"));
};
const getIcon = (type: string) => {
switch (type) {
case "info":
return <Info className="h-4 w-4 text-blue-500 shrink-0" />;
case "success":
return <CheckCircle2 className="h-4 w-4 text-green-500 shrink-0" />;
case "warning":
return <AlertTriangle className="h-4 w-4 text-yellow-500 shrink-0" />;
case "error":
return <XCircle className="h-4 w-4 text-red-500 shrink-0" />;
default:
return <Info className="h-4 w-4 shrink-0" />;
}
};
const getTextColor = (type: string) => {
switch (type) {
case "info":
return "text-blue-400";
case "success":
return "text-green-400";
case "warning":
return "text-yellow-400";
case "error":
return "text-red-400";
default:
return "text-muted-foreground";
}
};
return (
<div
className={cn(
"relative z-10 shrink-0 flex flex-col bg-bg-base",
position === "bottom"
? "border-t border-border"
: "border-b border-border",
expanded ? EXPANDED_HEIGHT : COLLAPSED_HEIGHT,
"transition-[height] duration-150",
className,
)}
>
<div className="flex items-center justify-between px-3 py-1.5 shrink-0 border-b border-border/60">
<Button
variant="ghost"
size="sm"
onClick={handleToggle}
className="flex items-center gap-2 -ml-2"
>
{expanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronUp className="h-4 w-4" />
)}
<span className="text-sm font-medium">
{t("terminal.connectionLogTitle")} ({logs.length})
</span>
</Button>
{logs.length > 0 && (
<Button
variant="ghost"
size="icon-sm"
onClick={copyLogsToClipboard}
title={t("terminal.connectionLogCopy")}
>
<Copy className="h-4 w-4" />
</Button>
)}
</div>
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden thin-scrollbar">
<div className="px-3 py-2">
{logs.length === 0 ? (
<div className="py-4 text-center text-sm text-muted-foreground">
{isConnecting
? t("terminal.connectionLogWaiting")
: t("terminal.connectionLogEmpty")}
</div>
) : (
<div className="space-y-1 font-mono text-xs">
{logs.map((log, index) => (
<div
key={log.id}
ref={index === logs.length - 1 ? lastLogRef : null}
className="flex items-start gap-2"
>
<span className="shrink-0 text-muted-foreground">
{log.timestamp.toLocaleTimeString()}
</span>
{getIcon(log.type)}
<span
className={cn(
"flex-1 min-w-0 break-all whitespace-pre-wrap",
getTextColor(log.type),
)}
>
{log.message}
</span>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,127 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils.ts";
import { Button } from "@/components/button.tsx";
import { RefreshCw } from "lucide-react";
import { ConnectionLogPanel } from "@/components/connection/ConnectionLogPanel.tsx";
import type { ConnectionStatus } from "@/components/connection/connection-status.ts";
interface ConnectionScreenProps {
status: ConnectionStatus;
message?: string;
backgroundColor?: string;
attempt?: number;
maxAttempts?: number;
nextRetryInMs?: number | null;
onManualRetry?: () => void;
retryLabel?: string;
disconnectedMessage?: string;
extraActions?: React.ReactNode;
logPosition?: "top" | "bottom";
emptyState?: React.ReactNode;
className?: string;
}
export function ConnectionScreen({
status,
message,
backgroundColor,
attempt = 0,
maxAttempts = 0,
nextRetryInMs = null,
onManualRetry,
retryLabel,
disconnectedMessage,
extraActions,
logPosition = "bottom",
emptyState,
className,
}: ConnectionScreenProps) {
const { t } = useTranslation();
if (status === "connected" && !emptyState) {
return null;
}
const showSpinner = status === "connecting";
const showRetryButton = status === "disconnected" && !!onManualRetry;
const showLog = status !== "connected";
return (
<div
className={cn("absolute inset-0 z-[100] flex flex-col", className)}
style={{ backgroundColor: backgroundColor || "var(--bg-base)" }}
>
<div className="flex-1 min-h-0 flex items-center justify-center">
{emptyState ? (
emptyState
) : (
<div className="flex flex-col items-center gap-4 px-6 text-center">
{showSpinner && <div className="simple-spinner" />}
{message && (
<p className="text-sm text-foreground-secondary font-medium">
{message}
</p>
)}
{attempt > 0 && status !== "disconnected" && (
<p className="text-xs text-muted-foreground">
{nextRetryInMs && nextRetryInMs > 0
? t("connection.retryingIn", {
seconds: Math.ceil(nextRetryInMs / 1000),
attempt,
max: maxAttempts,
})
: t("connection.retryingNow", { attempt, max: maxAttempts })}
</p>
)}
{showRetryButton && (
<div className="flex flex-col items-center gap-2">
<p className="text-sm text-foreground-secondary font-medium">
{disconnectedMessage || t("connection.disconnected")}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="default"
onClick={onManualRetry}
className="gap-2 font-semibold"
>
<RefreshCw className="size-3.5" />
{retryLabel || t("connection.reconnect")}
</Button>
{extraActions}
</div>
</div>
)}
</div>
)}
</div>
{showLog && !emptyState && (
<ConnectionLogPanel
isConnecting={status === "connecting"}
isConnected={false}
hasConnectionError={status === "error" || status === "disconnected"}
position={logPosition}
/>
)}
<style>
{`
@keyframes connection-screen-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.simple-spinner {
width: 40px;
height: 40px;
border: 4px solid var(--border-base);
border-top-color: var(--foreground);
border-radius: 50%;
animation: connection-screen-spin 0.8s linear infinite;
}
`}
</style>
</div>
);
}
@@ -0,0 +1,40 @@
import type { ConnectionStage } from "@/types/connection-log.ts";
export type ConnectionStatus =
"connecting" | "connected" | "error" | "disconnected";
// Guacamole client states, per guacamole-common-js Guacamole.Client#STATE_*.
const GUAC_STATE_IDLE = 0;
const GUAC_STATE_CONNECTING = 1;
const GUAC_STATE_WAITING = 2;
const GUAC_STATE_CONNECTED = 3;
const GUAC_STATE_DISCONNECTING = 4;
const GUAC_STATE_DISCONNECTED = 5;
export function guacStateToStage(state: number): ConnectionStage {
switch (state) {
case GUAC_STATE_CONNECTING:
return "guac_connecting";
case GUAC_STATE_WAITING:
return "guac_handshake";
case GUAC_STATE_CONNECTED:
return "guac_ready";
case GUAC_STATE_DISCONNECTING:
case GUAC_STATE_DISCONNECTED:
return "guac_disconnected";
case GUAC_STATE_IDLE:
default:
return "guac_connecting";
}
}
export function guacStateToStatus(state: number): ConnectionStatus {
switch (state) {
case GUAC_STATE_CONNECTED:
return "connected";
case GUAC_STATE_DISCONNECTED:
return "error";
default:
return "connecting";
}
}
+4
View File
@@ -238,11 +238,15 @@ function DropdownMenuSubTrigger({
function DropdownMenuSubContent({
className,
sideOffset = 4,
alignOffset = -4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
sideOffset={sideOffset}
alignOffset={alignOffset}
className={cn(
"z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-none bg-popover text-popover-foreground shadow-lg ring-1 ring-border duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
+45
View File
@@ -0,0 +1,45 @@
import type { LucideIcon } from "lucide-react";
import { useAreaPreferences } from "@/contexts/UiPreferencesContext";
/**
* Shared empty state. Every feature used to hand-roll its own, so some
* surfaces explained what to do next and others showed a bare line of text.
*
* The `hint` and `action` only render at "guided" verbosity, which the Simple
* preset turns on: someone who has just picked the simplest interface is the
* one who most needs telling what to do next, while an experienced user on
* Advanced does not want the extra paragraph on every empty panel.
*/
export function EmptyState({
icon: Icon,
title,
hint,
action,
className,
}: {
icon?: LucideIcon;
title: string;
/** Guidance shown only at "guided" verbosity. */
hint?: string;
/** Primary call to action, also guided-only. */
action?: React.ReactNode;
className?: string;
}) {
const { emptyStateVerbosity } = useAreaPreferences("chrome");
const guided = emptyStateVerbosity === "guided";
return (
<div
className={`flex flex-col items-center justify-center gap-2 px-4 py-8 text-center ${className ?? ""}`}
>
{Icon && <Icon className="size-5 shrink-0 text-muted-foreground/40" />}
<span className="text-xs text-muted-foreground">{title}</span>
{guided && hint && (
<span className="max-w-xs text-[11px] leading-snug text-muted-foreground/70">
{hint}
</span>
)}
{guided && action}
</div>
);
}
+1 -1
View File
@@ -28,7 +28,7 @@ function PopoverContent({
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-none border border-border p-4 shadow-md outline-hidden",
className,
)}
{...props}
@@ -1,4 +1,5 @@
import { useRef, useState } from "react";
import type { HostData } from "@/types/index";
import { useTranslation } from "react-i18next";
import { Server, RefreshCw, CheckSquare, Square, Download } from "lucide-react";
import { toast } from "sonner";
@@ -166,7 +167,13 @@ export function ProxmoxDiscoverDialog({
enableRdp: g.connectionType === "rdp",
enableDocker: g.enableDocker,
connectionType: g.connectionType,
tags: ["proxmox", g.type, g.node],
tags: [
"proxmox",
g.type,
g.node,
g.type === "lxc" ? `ct-${g.vmid}` : `vm-${g.vmid}`,
...(g.enableDocker ? ["docker"] : []),
],
proxmoxConfig: {
source: {
source: "proxmox",
@@ -182,7 +189,7 @@ export function ProxmoxDiscoverDialog({
}));
const result = toImport.length
? await bulkImportSSHHosts(toImport, false)
? await bulkImportSSHHosts(toImport as unknown as HostData[], false)
: { success: 0, updated: 0, skipped: 0, failed: 0 };
if (toImport.length) {
+5 -1
View File
@@ -6,14 +6,18 @@ export function SectionCard({
icon,
action,
children,
className,
}: {
title: string;
icon: React.ReactNode;
action?: React.ReactNode;
children: React.ReactNode;
className?: string;
}) {
return (
<div className="flex flex-col border border-border bg-card overflow-hidden">
<div
className={`flex flex-col border border-border bg-card overflow-hidden ${className ?? ""}`}
>
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border shrink-0">
<span className="text-muted-foreground">{icon}</span>
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground flex-1">
+3 -3
View File
@@ -35,7 +35,7 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-none border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -59,7 +59,7 @@ function SelectContent({
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto thin-scrollbar rounded-md border shadow-md",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto thin-scrollbar rounded-none border border-border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
@@ -105,7 +105,7 @@ function SelectItem({
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-none py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
@@ -1,71 +0,0 @@
import type { ComponentProps, HTMLAttributes } from "react";
import { Badge } from "@/components/badge";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
export type StatusProps = ComponentProps<typeof Badge> & {
status: "online" | "offline" | "maintenance" | "degraded";
};
export const Status = ({ className, status, ...props }: StatusProps) => (
<Badge
className={cn("flex items-center gap-2", "group", status, className)}
variant="secondary"
{...props}
/>
);
export type StatusIndicatorProps = HTMLAttributes<HTMLSpanElement>;
export const StatusIndicator = ({ ...props }: StatusIndicatorProps) => (
<span className="relative flex h-2 w-2" {...props}>
<span
className={cn(
"absolute inline-flex h-full w-full animate-ping rounded-full opacity-75",
"group-[.online]:bg-emerald-500",
"group-[.offline]:bg-red-500",
"group-[.maintenance]:bg-blue-500",
"group-[.degraded]:bg-amber-500",
)}
/>
<span
className={cn(
"relative inline-flex h-2 w-2 rounded-full",
"group-[.online]:bg-emerald-500",
"group-[.offline]:bg-red-500",
"group-[.maintenance]:bg-blue-500",
"group-[.degraded]:bg-amber-500",
)}
/>
</span>
);
export type StatusLabelProps = HTMLAttributes<HTMLSpanElement>;
export const StatusLabel = ({
className,
children,
...props
}: StatusLabelProps) => {
const { t } = useTranslation();
return (
<span className={cn("text-muted-foreground", className)} {...props}>
{children ?? (
<>
<span className="hidden group-[.online]:block">
{t("common.online")}
</span>
<span className="hidden group-[.offline]:block">
{t("common.offline")}
</span>
<span className="hidden group-[.maintenance]:block">
{t("common.maintenance")}
</span>
<span className="hidden group-[.degraded]:block">
{t("common.degraded")}
</span>
</>
)}
</span>
);
};
+2 -2
View File
@@ -24,7 +24,7 @@ function TabsList({
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-none p-[3px]",
className,
)}
{...props}
@@ -40,7 +40,7 @@ function TabsTrigger({
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] duration-200 focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-none border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] duration-200 focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
+1 -1
View File
@@ -9,7 +9,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-transparent dark:bg-input/30 px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] duration-200 outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 disabled:pointer-events-none",
"flex min-h-[80px] w-full rounded-none border border-input bg-transparent dark:bg-input/30 px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] duration-200 outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 disabled:pointer-events-none",
className,
)}
ref={ref}
+52
View File
@@ -0,0 +1,52 @@
import { useTranslation } from "react-i18next";
interface VersionBadgeProps {
status: "up_to_date" | "requires_update" | "beta";
releaseUrl?: string;
className?: string;
}
export function VersionBadge({
status,
releaseUrl,
className = "",
}: VersionBadgeProps) {
const { t } = useTranslation();
const badgeClassName = `text-[10px] px-1.5 py-0.5 font-semibold leading-none ${
status === "beta"
? "bg-blue-500/20 text-blue-400"
: status === "requires_update"
? "bg-yellow-500/20 text-yellow-400"
: "bg-accent-brand/20 text-accent-brand"
}${className ? ` ${className}` : ""}`;
const label =
status === "beta"
? t("dashboard.beta").toUpperCase()
: status === "requires_update"
? t("dashboard.updateAvailable").toUpperCase()
: t("dashboardTab.stable");
// Only the update case leads anywhere. Wherever this badge is rendered it is
// the one place a pending release is announced, so it also has to be the way
// to reach it -- otherwise "UPDATE AVAILABLE" is a dead end. The label alone
// does not say where the link goes, hence the spelled-out accessible name.
if (status === "requires_update" && releaseUrl) {
const linkLabel = t("versionCheck.updateLinkLabel");
return (
<a
href={releaseUrl}
target="_blank"
rel="noopener noreferrer"
title={linkLabel}
aria-label={linkLabel}
className={`${badgeClassName} cursor-pointer hover:underline`}
>
{label}
</a>
);
}
return <span className={badgeClassName}>{label}</span>;
}
@@ -0,0 +1,92 @@
/* eslint-disable react-refresh/only-export-components */
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { getUserPreferences, saveUserPreferences } from "@/api/open-tabs-api";
import {
parseRemoteDesktopDefaults,
parseTerminalDefaults,
type RemoteDesktopDefaults,
type TerminalDefaults,
} from "@/lib/connection-defaults";
type DefaultsKind = "terminal" | "rdp";
interface ConnectionDefaultsContextValue {
ready: boolean;
terminal: TerminalDefaults;
rdp: RemoteDesktopDefaults;
saveDefaults: (
kind: DefaultsKind,
value: TerminalDefaults | RemoteDesktopDefaults,
) => Promise<void>;
}
const ConnectionDefaultsContext = createContext<ConnectionDefaultsContextValue>(
{
ready: true,
terminal: {},
rdp: {},
saveDefaults: async () => {},
},
);
export function ConnectionDefaultsProvider({
children,
}: {
children: ReactNode;
}) {
const [ready, setReady] = useState(false);
const [terminal, setTerminal] = useState<TerminalDefaults>({});
const [rdp, setRdp] = useState<RemoteDesktopDefaults>({});
useEffect(() => {
let cancelled = false;
getUserPreferences()
.then((preferences) => {
if (cancelled) return;
setTerminal(parseTerminalDefaults(preferences.terminalDefaults));
setRdp(parseRemoteDesktopDefaults(preferences.rdpDefaults));
})
.catch(() => {})
.finally(() => {
if (!cancelled) setReady(true);
});
return () => {
cancelled = true;
};
}, []);
const saveDefaults = useCallback(
async (
kind: DefaultsKind,
value: TerminalDefaults | RemoteDesktopDefaults,
) => {
const serialized = JSON.stringify(value);
await saveUserPreferences({ [`${kind}Defaults`]: serialized });
if (kind === "terminal") setTerminal(value as TerminalDefaults);
if (kind === "rdp") setRdp(value as RemoteDesktopDefaults);
},
[],
);
const value = useMemo(
() => ({ ready, terminal, rdp, saveDefaults }),
[ready, terminal, rdp, saveDefaults],
);
return (
<ConnectionDefaultsContext.Provider value={value}>
{children}
</ConnectionDefaultsContext.Provider>
);
}
export function useConnectionDefaults(): ConnectionDefaultsContextValue {
return useContext(ConnectionDefaultsContext);
}
+292
View File
@@ -0,0 +1,292 @@
/* eslint-disable react-refresh/only-export-components */
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import {
getUiPreferences,
saveUiPreferences,
getUserPreferences,
} from "@/main-axios";
import {
defaultUiPreferences,
PRESETS,
resolveArea,
sanitizeUiPreferences,
UI_ONBOARDING_VERSION,
type UiAreaKey,
type UiAreaPreferences,
type UiOverrides,
type UiPreferences,
type UiPreset,
} from "@/types/ui-preferences";
const SAVE_DEBOUNCE_MS = 500;
const LS_KEY = "uiPreferences";
const SYNC_EVENT = "uiPreferencesChanged";
function readCache(): UiPreferences | null {
try {
const raw = localStorage.getItem(LS_KEY);
if (!raw) return null;
return sanitizeUiPreferences(JSON.parse(raw));
} catch {
return null;
}
}
function writeCache(preferences: UiPreferences) {
try {
localStorage.setItem(LS_KEY, JSON.stringify(preferences));
} catch {
/* ignore */
}
}
interface UiPreferencesContextValue {
preferences: UiPreferences;
loaded: boolean;
resolve: <A extends UiAreaKey>(area: A) => UiAreaPreferences[A];
setPreset: (preset: UiPreset) => void;
setOverride: <A extends UiAreaKey, K extends keyof UiAreaPreferences[A]>(
area: A,
key: K,
value: UiAreaPreferences[A][K] | null,
) => void;
clearArea: (area: UiAreaKey) => void;
clearAllOverrides: () => void;
completeOnboarding: (skipped: boolean) => void;
}
const UiPreferencesContext = createContext<UiPreferencesContextValue | null>(
null,
);
/**
* App-wide interface preset and per-area overrides. Cached in localStorage for
* instant paint and synced to the backend when storageMode is "cloud", the
* same shape as useHostSidebarPreferences -- but provided once at the app root
* rather than mounted per consumer, since almost every panel reads it.
*
* Writes send only the changed slice: the backend merges overrides two levels
* deep and treats null as "clear this", so handing a knob back to the preset is
* a single PUT rather than a read-modify-write of the whole document.
*/
export function UiPreferencesProvider({ children }: { children: ReactNode }) {
const [preferences, setPreferences] = useState<UiPreferences>(
() => readCache() ?? defaultUiPreferences(),
);
const [loaded, setLoaded] = useState(false);
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingPatch = useRef<Record<string, unknown>>({});
useEffect(() => {
let cancelled = false;
getUiPreferences()
.then((remote) => {
if (cancelled) return;
setPreferences(remote);
writeCache(remote);
})
.catch(() => {
/* keep cache/default */
})
.finally(() => {
if (!cancelled) setLoaded(true);
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
const handler = () => {
const cached = readCache();
if (cached) setPreferences(cached);
};
window.addEventListener(SYNC_EVENT, handler);
return () => window.removeEventListener(SYNC_EVENT, handler);
}, []);
useEffect(() => {
return () => {
if (saveTimer.current) clearTimeout(saveTimer.current);
};
}, []);
const queueSave = useCallback((patch: Record<string, unknown>) => {
// Overrides accumulate key by key so a burst of edits still sends every
// change; anything else is last-write-wins.
const merged = { ...pendingPatch.current };
for (const [key, value] of Object.entries(patch)) {
if (key === "overrides" && value && typeof value === "object") {
merged.overrides = {
...((merged.overrides as Record<string, unknown>) ?? {}),
...(value as Record<string, unknown>),
};
} else {
merged[key] = value;
}
}
pendingPatch.current = merged;
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
const body = pendingPatch.current;
pendingPatch.current = {};
getUserPreferences()
.then((prefs) => {
if (prefs.storageMode !== "cloud") return;
return saveUiPreferences(body);
})
.catch(() => {
/* best-effort; cache already holds it */
});
}, SAVE_DEBOUNCE_MS);
}, []);
/** Applies the local mirror of a patch, then queues the same patch remotely. */
const applyLocal = useCallback(
(
mutate: (prev: UiPreferences) => UiPreferences,
patch: Record<string, unknown>,
) => {
setPreferences((prev) => {
const next = sanitizeUiPreferences(mutate(prev));
writeCache(next);
window.dispatchEvent(new Event(SYNC_EVENT));
return next;
});
queueSave(patch);
},
[queueSave],
);
const setPreset = useCallback(
(preset: UiPreset) => {
applyLocal((prev) => ({ ...prev, preset }), { preset });
},
[applyLocal],
);
const setOverride = useCallback(
<A extends UiAreaKey, K extends keyof UiAreaPreferences[A]>(
area: A,
key: K,
value: UiAreaPreferences[A][K] | null,
) => {
applyLocal(
(prev) => {
const areaOverrides = {
...((prev.overrides[area] ?? {}) as Record<string, unknown>),
};
if (value === null) delete areaOverrides[key as string];
else areaOverrides[key as string] = value;
const overrides = { ...prev.overrides } as Record<string, unknown>;
if (Object.keys(areaOverrides).length > 0)
overrides[area] = areaOverrides;
else delete overrides[area];
return {
...prev,
overrides: overrides as UiOverrides,
};
},
{ overrides: { [area]: { [key]: value } } },
);
},
[applyLocal],
);
const clearArea = useCallback(
(area: UiAreaKey) => {
applyLocal(
(prev) => {
const overrides = { ...prev.overrides } as Record<string, unknown>;
delete overrides[area];
return { ...prev, overrides: overrides as UiOverrides };
},
{ overrides: { [area]: null } },
);
},
[applyLocal],
);
const clearAllOverrides = useCallback(() => {
applyLocal((prev) => ({ ...prev, overrides: {} }), { overrides: null });
}, [applyLocal]);
const completeOnboarding = useCallback(
(skipped: boolean) => {
const onboarding = {
completedVersion: UI_ONBOARDING_VERSION,
completedAt: new Date().toISOString(),
skipped,
};
applyLocal((prev) => ({ ...prev, onboarding }), { onboarding });
},
[applyLocal],
);
const value = useMemo<UiPreferencesContextValue>(
() => ({
preferences,
loaded,
resolve: (area) => resolveArea(preferences, area),
setPreset,
setOverride,
clearArea,
clearAllOverrides,
completeOnboarding,
}),
[
preferences,
loaded,
setPreset,
setOverride,
clearArea,
clearAllOverrides,
completeOnboarding,
],
);
return (
<UiPreferencesContext.Provider value={value}>
{children}
</UiPreferencesContext.Provider>
);
}
export function useUiPreferencesContext(): UiPreferencesContextValue | null {
return useContext(UiPreferencesContext);
}
/**
* Effective settings for one area. Falls back to the balanced preset (today's
* behavior) outside a provider, so components rendered in isolation -- tests,
* Electron sub-windows -- behave exactly as they did before presets existed
* rather than crashing or silently going Simple.
*/
export function useAreaPreferences<A extends UiAreaKey>(
area: A,
): UiAreaPreferences[A] {
const ctx = useContext(UiPreferencesContext);
if (!ctx) return PRESETS.balanced[area];
return ctx.resolve(area);
}
export function useUiPreference<
A extends UiAreaKey,
K extends keyof UiAreaPreferences[A],
>(area: A, key: K): UiAreaPreferences[A][K] {
return useAreaPreferences(area)[key];
}
+83 -38
View File
@@ -24,12 +24,15 @@ import {
Zap,
} from "lucide-react";
import { Kbd } from "@/components/kbd";
import { VersionBadge } from "@/components/version-badge";
import { DASHBOARD_CARDS } from "@/lib/theme";
import { CONNECTION_STATES } from "@/types/index";
import type { DashboardCardId, TabType, Host } from "@/types/ui-types";
import {
getSSHHosts,
getUptime,
getVersionInfo,
releaseUrlFrom,
getDatabaseHealth,
getRecentActivity,
getTunnelStatuses,
@@ -130,28 +133,18 @@ function StatsBarCard({
uptimeFormatted,
versionText,
versionStatus,
releaseUrl,
dbHealth,
}: {
hosts: Host[];
uptimeFormatted: string;
versionText: string;
versionStatus: "up_to_date" | "requires_update" | "beta";
releaseUrl: string;
dbHealth: "healthy" | "error";
}) {
const { t } = useTranslation();
const online = hosts.filter((h) => h.online).length;
const statusLabel =
versionStatus === "beta"
? t("dashboard.beta").toUpperCase()
: versionStatus === "requires_update"
? t("dashboard.updateAvailable").toUpperCase()
: t("dashboardTab.stable");
const statusColor =
versionStatus === "beta"
? "bg-blue-500/20 text-blue-400"
: versionStatus === "requires_update"
? "bg-yellow-500/20 text-yellow-400"
: "bg-accent-brand/20 text-accent-brand";
const online = hosts.filter((h) => h.status === "online").length;
return (
<Card className="grid grid-cols-4 divide-x divide-border overflow-hidden w-full h-full py-0 gap-0">
<div className="flex flex-col justify-center px-4 py-2 gap-1">
@@ -161,11 +154,11 @@ function StatsBarCard({
<span className="text-xl font-bold text-accent-brand leading-none">
{versionText || "—"}
</span>
<span
className={`text-[10px] px-1.5 py-0.5 w-fit font-semibold leading-none ${statusColor}`}
>
{statusLabel}
</span>
<VersionBadge
status={versionStatus}
releaseUrl={releaseUrl}
className="w-fit"
/>
</div>
<div className="flex flex-col justify-center px-4 py-2 gap-1">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
@@ -189,7 +182,9 @@ function StatsBarCard({
</div>
<div className="flex flex-col justify-center px-4 py-2 gap-1">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
{t("dashboardTab.hostsOnline")}
{t("dashboardTab.hostsAvailable", {
defaultValue: "Hosts Available",
})}
</span>
<div className="flex items-baseline gap-1">
<span className="text-xl font-bold leading-none">{online}</span>
@@ -426,7 +421,7 @@ function MetricBar({ label, value }: { label: string; value: number }) {
);
}
function HostStatusCard({
export function HostStatusCard({
hosts,
hostMetrics,
onOpenTab,
@@ -442,7 +437,7 @@ function HostStatusCard({
}) {
const { t } = useTranslation();
const statusScheme = useStatusColorScheme();
const online = hosts.filter((h) => h.online).length;
const online = hosts.filter((h) => h.status === "online").length;
return (
<Card className="flex flex-col overflow-hidden w-full h-full py-0 gap-0">
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
@@ -453,7 +448,8 @@ function HostStatusCard({
</span>
</div>
<span className="text-xs text-muted-foreground">
{online}/{hosts.length} {t("dashboardTab.onlineLower")}
{online}/{hosts.length}{" "}
{t("dashboardTab.availableLower", { defaultValue: "Available" })}
</span>
</div>
<div className="flex flex-col overflow-auto flex-1">
@@ -463,6 +459,12 @@ function HostStatusCard({
</div>
)}
{hosts.map((host, i) => {
const availability =
host.status && host.status !== "unknown"
? host.status
: host.online
? "online"
: "offline";
const metrics = hostMetrics.get(host.id);
const cpu = metrics?.cpu ?? null;
const ram = metrics?.ram ?? null;
@@ -472,24 +474,32 @@ function HostStatusCard({
<div
key={i}
onClick={() => onOpenTab(host, "host-metrics")}
className="flex items-center justify-between px-4 py-2.5 border-b border-border last:border-0 hover:bg-muted/50 cursor-pointer group/row"
className="flex min-w-0 items-center justify-between px-4 py-2.5 border-b border-border last:border-0 hover:bg-muted/50 cursor-pointer group/row"
>
<div className="flex items-center gap-2.5">
<div className="flex min-w-0 flex-1 items-center gap-2.5">
<span
className={`size-1.5 rounded-full shrink-0 ${getStatusClasses(host.online, statusScheme, "dot", statusLoading)}`}
className={`size-1.5 rounded-full shrink-0 ${getStatusClasses(availability, statusScheme, "dot", statusLoading)}`}
/>
<div className="flex flex-col">
<div className="flex items-center gap-1">
<span className="text-xs font-semibold">{host.name}</span>
<div className="flex min-w-0 flex-col">
<div className="flex min-w-0 items-center gap-1">
<span
className="truncate text-xs font-semibold"
title={host.name}
>
{host.name}
</span>
<ExternalLink className="size-2.5 text-muted-foreground/0 group-hover/row:text-muted-foreground/60 transition-colors shrink-0" />
</div>
<span className="text-[10px] text-muted-foreground font-mono">
<span
className="truncate text-[10px] text-muted-foreground font-mono"
title={host.ip}
>
{host.ip}
</span>
</div>
</div>
<div className="flex items-center gap-3">
{host.online && hasMetrics ? (
<div className="flex shrink-0 items-center gap-3">
{availability === "online" && hasMetrics ? (
<div className="flex items-center gap-3">
{cpu !== null && (
<MetricBar label={t("dashboard.cpu")} value={cpu} />
@@ -515,13 +525,19 @@ function HostStatusCard({
</div>
)}
<span
className={`text-[10px] px-2 py-0.5 font-semibold border ${getStatusClasses(host.online, statusScheme, "badge", statusLoading)}`}
className={`text-[10px] px-2 py-0.5 font-semibold border ${getStatusClasses(availability, statusScheme, "badge", statusLoading)}`}
>
{statusLoading
? t("dashboardTab.checking")
: host.online
? t("dashboardTab.online")
: t("dashboardTab.offline")}
: availability === "online"
? t("dashboardTab.available", {
defaultValue: "AVAILABLE",
})
: availability === "reachable"
? t("dashboardTab.reachable", {
defaultValue: "REACHABLE",
})
: t("dashboardTab.offline")}
</span>
</div>
</div>
@@ -792,6 +808,7 @@ function CardItem({
uptimeFormatted,
versionText,
versionStatus,
releaseUrl,
dbHealth,
credentialCount,
activeTunnelCount,
@@ -822,6 +839,7 @@ function CardItem({
uptimeFormatted: string;
versionText: string;
versionStatus: "up_to_date" | "requires_update" | "beta";
releaseUrl: string;
dbHealth: "healthy" | "error";
credentialCount: number;
activeTunnelCount: number;
@@ -890,6 +908,7 @@ function CardItem({
uptimeFormatted={uptimeFormatted}
versionText={versionText}
versionStatus={versionStatus}
releaseUrl={releaseUrl}
dbHealth={dbHealth}
/>
)}
@@ -906,6 +925,7 @@ function CardItem({
onOpenSingletonTab={onOpenSingletonTab}
hosts={hosts}
onOpenTab={onOpenTab}
isAdmin={isAdmin}
/>
)}
{slot.id === "host_status" && (
@@ -913,7 +933,6 @@ function CardItem({
hosts={hosts}
hostMetrics={hostMetrics}
onOpenTab={onOpenTab}
isAdmin={isAdmin}
statusLoading={statusLoading}
/>
)}
@@ -1048,6 +1067,7 @@ type PanelColumnProps = {
uptimeFormatted: string;
versionText: string;
versionStatus: "up_to_date" | "requires_update" | "beta";
releaseUrl: string;
dbHealth: "healthy" | "error";
credentialCount: number;
activeTunnelCount: number;
@@ -1080,6 +1100,7 @@ function PanelColumn({
uptimeFormatted,
versionText,
versionStatus,
releaseUrl,
dbHealth,
credentialCount,
activeTunnelCount,
@@ -1138,6 +1159,7 @@ function PanelColumn({
uptimeFormatted={uptimeFormatted}
versionText={versionText}
versionStatus={versionStatus}
releaseUrl={releaseUrl}
dbHealth={dbHealth}
credentialCount={credentialCount}
activeTunnelCount={activeTunnelCount}
@@ -1223,6 +1245,25 @@ export function DashboardTab({
return DEFAULT_SLOTS;
});
// Picking an interface preset rewrites the stored layout from the settings
// panel, so pick it up without waiting for a remount.
useEffect(() => {
const handler = () => {
try {
const saved = localStorage.getItem("dashboardTab.slots");
if (!saved) return;
const parsed = JSON.parse(saved) as CardSlot[];
setSlots(
parsed.map((s, i) => ({ key: s.key ?? `${s.id}_${i}`, ...s })),
);
} catch {
/* ignore */
}
};
window.addEventListener("dashboardSlotsChanged", handler);
return () => window.removeEventListener("dashboardSlotsChanged", handler);
}, []);
const [homepageLinkCopied, setHomepageLinkCopied] = useState(false);
const handleCopyHomepageLink = () => {
@@ -1288,6 +1329,7 @@ export function DashboardTab({
const [versionStatus, setVersionStatus] = useState<
"up_to_date" | "requires_update" | "beta"
>("up_to_date");
const [releaseUrl, setReleaseUrl] = useState("");
const [dbHealth, setDbHealth] = useState<"healthy" | "error">("healthy");
const [credentialCount, setCredentialCount] = useState(0);
const [activeTunnelCount, setActiveTunnelCount] = useState(0);
@@ -1380,6 +1422,7 @@ export function DashboardTab({
.then((info) => {
setVersionText(info.localVersion ?? "");
setVersionStatus(info.status ?? "up_to_date");
setReleaseUrl(releaseUrlFrom(info));
})
.catch(() => {});
getDatabaseHealth()
@@ -1410,7 +1453,7 @@ export function DashboardTab({
getTunnelStatuses()
.then((statuses) => {
const active = Object.values(statuses ?? {}).filter(
(s) => s?.status === "CONNECTED",
(s) => s?.status === CONNECTION_STATES.CONNECTED,
).length;
setActiveTunnelCount(active);
})
@@ -1606,6 +1649,7 @@ export function DashboardTab({
uptimeFormatted,
versionText,
versionStatus,
releaseUrl,
dbHealth,
credentialCount,
activeTunnelCount,
@@ -1720,6 +1764,7 @@ export function DashboardTab({
uptimeFormatted={uptimeFormatted}
versionText={versionText}
versionStatus={versionStatus}
releaseUrl={releaseUrl}
dbHealth={dbHealth}
/>
)}
@@ -1736,6 +1781,7 @@ export function DashboardTab({
onOpenSingletonTab={onOpenSingletonTab}
hosts={hosts}
onOpenTab={onOpenTab}
isAdmin={isAdmin}
/>
)}
{slot.id === "host_status" && (
@@ -1743,7 +1789,6 @@ export function DashboardTab({
hosts={statusCheckHosts}
hostMetrics={hostMetrics}
onOpenTab={onOpenTab}
isAdmin={isAdmin}
statusLoading={statusLoading}
/>
)}
+10 -3
View File
@@ -62,6 +62,7 @@ import {
import { useTranslation } from "react-i18next";
import { useTabsSafe } from "@/shell/TabContext";
import { cn } from "@/lib/utils";
import { readStatusColorScheme } from "@/hooks/use-status-color-scheme";
const AVAILABLE_COLORS = [
{ value: "#ef4444", label: "Red" },
@@ -130,12 +131,14 @@ function buildNodeSvg(
): string {
const isOnline = status === "online";
const isOffline = status === "offline";
const useRealColors = localStorage.getItem("statusColorScheme") === "status";
const useRealColors = readStatusColorScheme() === "status";
let statusColor: string;
if (isOnline) {
statusColor = useRealColors
? "rgb(16,185,129)"
: resolveCssVar("--accent-brand", "rgb(16,185,129)");
} else if (status === "reachable") {
statusColor = "rgb(251,191,36)";
} else if (isOffline) {
statusColor = useRealColors ? "rgb(239,68,68)" : "rgba(16,185,129,0.2)";
} else {
@@ -495,9 +498,13 @@ export function NetworkGraphCard({
(cy: cytoscape.Core) => {
cyRef.current = cy;
if (embedded) {
cy.nodes().forEach((n) => n.ungrabify());
cy.nodes().forEach((n) => {
n.ungrabify();
});
} else {
cy.nodes().forEach((n) => n.grabify());
cy.nodes().forEach((n) => {
n.grabify();
});
}
applyStyle(cy);
-222
View File
@@ -1,222 +0,0 @@
import React, { useEffect, useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/alert.tsx";
import { Button } from "@/components/button.tsx";
import { Sheet, SheetContent } from "@/components/sheet.tsx";
import { getReleasesRSS, getVersionInfo } from "@/main-axios.ts";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
interface UpdateLogProps extends React.ComponentProps<"div"> {
loggedIn: boolean;
}
interface ReleaseItem {
id: number;
title: string;
description: string;
link: string;
pubDate: string;
version: string;
isPrerelease: boolean;
isDraft: boolean;
assets: Array<{
name: string;
size: number;
download_count: number;
download_url: string;
}>;
}
interface RSSResponse {
feed: {
title: string;
description: string;
link: string;
updated: string;
};
items: ReleaseItem[];
total_count: number;
cached: boolean;
cache_age?: number;
}
interface VersionResponse {
status: "up_to_date" | "requires_update" | "beta";
version: string;
localVersion?: string;
latest_release: {
name: string;
published_at: string;
html_url: string;
};
cached: boolean;
cache_age?: number;
}
export function UpdateLog({ loggedIn }: UpdateLogProps) {
const { t } = useTranslation();
const [releases, setReleases] = useState<RSSResponse | null>(null);
const [versionInfo, setVersionInfo] = useState<VersionResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
if (loggedIn && isOpen) {
setLoading(true);
Promise.all([getReleasesRSS(100), getVersionInfo()])
.then(([releasesRes, versionRes]) => {
setReleases(releasesRes);
setVersionInfo(versionRes);
setError(null);
})
.catch(() => {
setError(t("common.failedToFetchUpdateInfo"));
})
.finally(() => setLoading(false));
}
}, [loggedIn, isOpen, t]);
if (!loggedIn) {
return null;
}
const formatDescription = (description: string) => {
const firstLine = description.split("\n")[0];
return firstLine.replace(/[#*`]/g, "").replace(/\s+/g, " ").trim();
};
return (
<>
<Button
variant="outline"
size="sm"
className="ml-2 text-sm border-1 border-edge text-muted-foreground"
onClick={() => setIsOpen(true)}
>
{t("common.updatesAndReleases")}
</Button>
<Sheet open={isOpen} onOpenChange={setIsOpen}>
<SheetContent
side="right"
className="w-[500px] bg-canvas border-l-2 border-edge text-foreground sm:max-w-[500px] p-0 flex flex-col [&>button]:hidden"
>
<div className="flex items-center justify-between p-4 border-b border-edge">
<h2 className="text-lg font-semibold text-foreground">
{t("common.updatesAndReleases")}
</h2>
<Button
variant="outline"
size="sm"
onClick={() => setIsOpen(false)}
className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center"
title={t("common.close")}
>
<X />
</Button>
</div>
<div className="flex-1 overflow-y-auto p-4 thin-scrollbar">
{versionInfo && versionInfo.status === "requires_update" && (
<Alert className="bg-elevated border-edge text-foreground mb-3">
<AlertTitle className="text-foreground">
{t("common.updateAvailable")}
</AlertTitle>
<AlertDescription className="text-foreground-secondary">
{t("common.newVersionAvailable", {
version: versionInfo.version,
})}
</AlertDescription>
</Alert>
)}
{versionInfo && versionInfo.status === "beta" && (
<Alert className="bg-elevated border-edge text-foreground mb-3">
<AlertTitle className="text-foreground">
{t("versionCheck.betaVersion")}
</AlertTitle>
<AlertDescription className="text-foreground-secondary">
{t("versionCheck.betaVersionDesc", {
current: versionInfo.localVersion,
latest: versionInfo.version,
})}
</AlertDescription>
</Alert>
)}
{loading && (
<div className="flex items-center justify-center h-32">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
</div>
)}
{error && (
<Alert
variant="destructive"
className="bg-red-900/20 border-red-500 text-red-300 mb-3"
>
<AlertTitle className="text-red-300">
{t("common.error")}
</AlertTitle>
<AlertDescription className="text-red-300">
{error}
</AlertDescription>
</Alert>
)}
<div className="space-y-3">
{releases?.items.map((release) => (
<div
key={release.id}
className="border border-edge rounded-lg p-3 hover:bg-elevated transition-colors cursor-pointer bg-elevated/50"
onClick={() => window.open(release.link, "_blank")}
>
<div className="flex items-start justify-between mb-2">
<h4 className="font-semibold text-sm leading-tight flex-1 text-foreground">
{release.title}
</h4>
{release.isPrerelease && (
<span className="text-xs bg-yellow-600 text-yellow-100 px-2 py-1 rounded ml-2 flex-shrink-0 font-medium">
{t("common.preRelease")}
</span>
)}
</div>
<p className="text-xs text-foreground-secondary mb-2 leading-relaxed">
{formatDescription(release.description)}
</p>
<div className="flex items-center text-xs text-muted-foreground">
<span>
{new Date(release.pubDate).toLocaleDateString()}
</span>
{release.assets.length > 0 && (
<>
<span className="mx-2"></span>
<span>
{release.assets.length} asset
{release.assets.length !== 1 ? "s" : ""}
</span>
</>
)}
</div>
</div>
))}
</div>
{releases && releases.items.length === 0 && !loading && (
<Alert className="bg-elevated border-edge text-foreground-secondary">
<AlertTitle className="text-foreground-secondary">
{t("common.noReleases")}
</AlertTitle>
<AlertDescription className="text-muted-foreground">
{t("common.noReleasesFound")}
</AlertDescription>
</Alert>
)}
</div>
</SheetContent>
</Sheet>
</>
);
}
+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";

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