Compare commits

..

8 Commits

10 changed files with 770 additions and 181 deletions
+169 -10
View File
@@ -1590,13 +1590,13 @@ func (s *Store) GetSSHSession(id string) (models.SSHSession, error) {
var item models.SSHSession
var err error
row = s.QueryRow(`SELECT ss.public_id, COALESCE(p.public_id, ''), COALESCE(s.public_id, ''), COALESCE(s.name, ''), COALESCE(u.public_id, ''), ss.username, ss.remote_username, ss.auth_method, ss.second_factor_mode, ss.host, ss.port, ss.status, ss.host_key_fingerprint, ss.requested_term, ss.requested_cols, ss.requested_rows, ss.started_at, ss.connected_at, ss.ended_at, ss.remote_addr, ss.user_agent, ss.error
row = s.QueryRow(`SELECT ss.public_id, COALESCE(p.public_id, ''), COALESCE(p.name, ''), COALESCE(s.public_id, ''), COALESCE(s.name, ''), COALESCE(u.public_id, ''), ss.username, ss.remote_username, ss.auth_method, ss.second_factor_mode, ss.host, ss.port, ss.status, ss.host_key_fingerprint, ss.requested_term, ss.requested_cols, ss.requested_rows, ss.started_at, ss.connected_at, ss.ended_at, ss.remote_addr, ss.user_agent, ss.error
FROM ssh_sessions ss
LEFT JOIN ssh_access_profiles p ON p.id = ss.profile_id
LEFT JOIN ssh_servers s ON s.id = ss.server_id
LEFT JOIN users u ON u.id = ss.user_id
WHERE ss.public_id = ?`, strings.TrimSpace(id))
err = row.Scan(&item.ID, &item.ProfileID, &item.ServerID, &item.ServerName, &item.UserID, &item.Username, &item.RemoteUsername, &item.AuthMethod, &item.SecondFactorMode, &item.Host, &item.Port, &item.Status, &item.HostKeyFingerprint, &item.RequestedTerm, &item.RequestedCols, &item.RequestedRows, &item.StartedAt, &item.ConnectedAt, &item.EndedAt, &item.RemoteAddr, &item.UserAgent, &item.Error)
err = row.Scan(&item.ID, &item.ProfileID, &item.ProfileName, &item.ServerID, &item.ServerName, &item.UserID, &item.Username, &item.RemoteUsername, &item.AuthMethod, &item.SecondFactorMode, &item.Host, &item.Port, &item.Status, &item.HostKeyFingerprint, &item.RequestedTerm, &item.RequestedCols, &item.RequestedRows, &item.StartedAt, &item.ConnectedAt, &item.EndedAt, &item.RemoteAddr, &item.UserAgent, &item.Error)
if err != nil {
return item, err
}
@@ -1641,14 +1641,14 @@ func (s *Store) ListSSHSessionsForUserFiltered(userID string, limit int, offset
args = append(args, strings.TrimSpace(userID))
if query != "" {
like = "%" + query + "%"
whereParts = append(whereParts, "(ss.public_id LIKE ? OR COALESCE(p.public_id, '') LIKE ? OR COALESCE(s.public_id, '') LIKE ? OR COALESCE(s.name, '') LIKE ? OR COALESCE(u.public_id, '') LIKE ? OR ss.username LIKE ? OR ss.remote_username LIKE ? OR ss.host LIKE ? OR ss.auth_method LIKE ? OR ss.status LIKE ? OR ss.host_key_fingerprint LIKE ? OR ss.error LIKE ?)")
args = append(args, like, like, like, like, like, like, like, like, like, like, like, like)
whereParts = append(whereParts, "(ss.public_id LIKE ? OR COALESCE(p.public_id, '') LIKE ? OR COALESCE(p.name, '') LIKE ? OR COALESCE(s.public_id, '') LIKE ? OR COALESCE(s.name, '') LIKE ? OR COALESCE(u.public_id, '') LIKE ? OR ss.username LIKE ? OR ss.remote_username LIKE ? OR ss.host LIKE ? OR ss.auth_method LIKE ? OR ss.status LIKE ? OR ss.host_key_fingerprint LIKE ? OR ss.error LIKE ?)")
args = append(args, like, like, like, like, like, like, like, like, like, like, like, like, like)
}
if status != "" {
whereParts = append(whereParts, "ss.status = ?")
args = append(args, status)
}
sqlQuery = fmt.Sprintf(`SELECT ss.public_id, COALESCE(p.public_id, ''), COALESCE(s.public_id, ''), COALESCE(s.name, ''), COALESCE(u.public_id, ''), ss.username, ss.remote_username, ss.auth_method, ss.second_factor_mode, ss.host, ss.port, ss.status, ss.host_key_fingerprint, ss.requested_term, ss.requested_cols, ss.requested_rows, ss.started_at, ss.connected_at, ss.ended_at, ss.remote_addr, ss.user_agent, ss.error
sqlQuery = fmt.Sprintf(`SELECT ss.public_id, COALESCE(p.public_id, ''), COALESCE(p.name, ''), COALESCE(s.public_id, ''), COALESCE(s.name, ''), COALESCE(u.public_id, ''), ss.username, ss.remote_username, ss.auth_method, ss.second_factor_mode, ss.host, ss.port, ss.status, ss.host_key_fingerprint, ss.requested_term, ss.requested_cols, ss.requested_rows, ss.started_at, ss.connected_at, ss.ended_at, ss.remote_addr, ss.user_agent, ss.error
FROM ssh_sessions ss
LEFT JOIN ssh_access_profiles p ON p.id = ss.profile_id
LEFT JOIN ssh_servers s ON s.id = ss.server_id
@@ -1663,7 +1663,7 @@ func (s *Store) ListSSHSessionsForUserFiltered(userID string, limit int, offset
}
defer rows.Close()
for rows.Next() {
err = rows.Scan(&item.ID, &item.ProfileID, &item.ServerID, &item.ServerName, &item.UserID, &item.Username, &item.RemoteUsername, &item.AuthMethod, &item.SecondFactorMode, &item.Host, &item.Port, &item.Status, &item.HostKeyFingerprint, &item.RequestedTerm, &item.RequestedCols, &item.RequestedRows, &item.StartedAt, &item.ConnectedAt, &item.EndedAt, &item.RemoteAddr, &item.UserAgent, &item.Error)
err = rows.Scan(&item.ID, &item.ProfileID, &item.ProfileName, &item.ServerID, &item.ServerName, &item.UserID, &item.Username, &item.RemoteUsername, &item.AuthMethod, &item.SecondFactorMode, &item.Host, &item.Port, &item.Status, &item.HostKeyFingerprint, &item.RequestedTerm, &item.RequestedCols, &item.RequestedRows, &item.StartedAt, &item.ConnectedAt, &item.EndedAt, &item.RemoteAddr, &item.UserAgent, &item.Error)
if err != nil {
return nil, false, err
}
@@ -1680,6 +1680,40 @@ func (s *Store) ListSSHSessionsForUserFiltered(userID string, limit int, offset
return items, false, nil
}
func (s *Store) CountSSHSessionsForUserFiltered(userID string, query string, status string) (int, error) {
var whereParts []string
var args []any
var sqlQuery string
var like string
var total int
var err error
query = strings.TrimSpace(query)
status = strings.ToLower(strings.TrimSpace(status))
whereParts = append(whereParts, "u.public_id = ?")
args = append(args, strings.TrimSpace(userID))
if query != "" {
like = "%" + query + "%"
whereParts = append(whereParts, "(ss.public_id LIKE ? OR COALESCE(p.public_id, '') LIKE ? OR COALESCE(p.name, '') LIKE ? OR COALESCE(s.public_id, '') LIKE ? OR COALESCE(s.name, '') LIKE ? OR COALESCE(u.public_id, '') LIKE ? OR ss.username LIKE ? OR ss.remote_username LIKE ? OR ss.host LIKE ? OR ss.auth_method LIKE ? OR ss.status LIKE ? OR ss.host_key_fingerprint LIKE ? OR ss.error LIKE ?)")
args = append(args, like, like, like, like, like, like, like, like, like, like, like, like, like)
}
if status != "" {
whereParts = append(whereParts, "ss.status = ?")
args = append(args, status)
}
sqlQuery = fmt.Sprintf(`SELECT COUNT(*)
FROM ssh_sessions ss
LEFT JOIN ssh_access_profiles p ON p.id = ss.profile_id
LEFT JOIN ssh_servers s ON s.id = ss.server_id
LEFT JOIN users u ON u.id = ss.user_id
WHERE %s`, strings.Join(whereParts, " AND "))
err = s.QueryRow(sqlQuery, args...).Scan(&total)
if err != nil {
return 0, err
}
return total, nil
}
func (s *Store) ListSSHSessionsFiltered(limit int, offset int, query string, status string) ([]models.SSHSession, bool, error) {
var rows *sql.Rows
var items []models.SSHSession
@@ -1703,14 +1737,14 @@ func (s *Store) ListSSHSessionsFiltered(limit int, offset int, query string, sta
status = strings.ToLower(strings.TrimSpace(status))
if query != "" {
like = "%" + query + "%"
whereParts = append(whereParts, "(ss.public_id LIKE ? OR COALESCE(p.public_id, '') LIKE ? OR COALESCE(s.public_id, '') LIKE ? OR COALESCE(s.name, '') LIKE ? OR COALESCE(u.public_id, '') LIKE ? OR ss.username LIKE ? OR ss.remote_username LIKE ? OR ss.host LIKE ? OR ss.auth_method LIKE ? OR ss.status LIKE ? OR ss.host_key_fingerprint LIKE ? OR ss.error LIKE ?)")
args = append(args, like, like, like, like, like, like, like, like, like, like, like, like)
whereParts = append(whereParts, "(ss.public_id LIKE ? OR COALESCE(p.public_id, '') LIKE ? OR COALESCE(p.name, '') LIKE ? OR COALESCE(s.public_id, '') LIKE ? OR COALESCE(s.name, '') LIKE ? OR COALESCE(u.public_id, '') LIKE ? OR ss.username LIKE ? OR ss.remote_username LIKE ? OR ss.host LIKE ? OR ss.auth_method LIKE ? OR ss.status LIKE ? OR ss.host_key_fingerprint LIKE ? OR ss.error LIKE ?)")
args = append(args, like, like, like, like, like, like, like, like, like, like, like, like, like)
}
if status != "" {
whereParts = append(whereParts, "ss.status = ?")
args = append(args, status)
}
sqlQuery = `SELECT ss.public_id, COALESCE(p.public_id, ''), COALESCE(s.public_id, ''), COALESCE(s.name, ''), COALESCE(u.public_id, ''), ss.username, ss.remote_username, ss.auth_method, ss.second_factor_mode, ss.host, ss.port, ss.status, ss.host_key_fingerprint, ss.requested_term, ss.requested_cols, ss.requested_rows, ss.started_at, ss.connected_at, ss.ended_at, ss.remote_addr, ss.user_agent, ss.error
sqlQuery = `SELECT ss.public_id, COALESCE(p.public_id, ''), COALESCE(p.name, ''), COALESCE(s.public_id, ''), COALESCE(s.name, ''), COALESCE(u.public_id, ''), ss.username, ss.remote_username, ss.auth_method, ss.second_factor_mode, ss.host, ss.port, ss.status, ss.host_key_fingerprint, ss.requested_term, ss.requested_cols, ss.requested_rows, ss.started_at, ss.connected_at, ss.ended_at, ss.remote_addr, ss.user_agent, ss.error
FROM ssh_sessions ss
LEFT JOIN ssh_access_profiles p ON p.id = ss.profile_id
LEFT JOIN ssh_servers s ON s.id = ss.server_id
@@ -1726,7 +1760,7 @@ func (s *Store) ListSSHSessionsFiltered(limit int, offset int, query string, sta
}
defer rows.Close()
for rows.Next() {
err = rows.Scan(&item.ID, &item.ProfileID, &item.ServerID, &item.ServerName, &item.UserID, &item.Username, &item.RemoteUsername, &item.AuthMethod, &item.SecondFactorMode, &item.Host, &item.Port, &item.Status, &item.HostKeyFingerprint, &item.RequestedTerm, &item.RequestedCols, &item.RequestedRows, &item.StartedAt, &item.ConnectedAt, &item.EndedAt, &item.RemoteAddr, &item.UserAgent, &item.Error)
err = rows.Scan(&item.ID, &item.ProfileID, &item.ProfileName, &item.ServerID, &item.ServerName, &item.UserID, &item.Username, &item.RemoteUsername, &item.AuthMethod, &item.SecondFactorMode, &item.Host, &item.Port, &item.Status, &item.HostKeyFingerprint, &item.RequestedTerm, &item.RequestedCols, &item.RequestedRows, &item.StartedAt, &item.ConnectedAt, &item.EndedAt, &item.RemoteAddr, &item.UserAgent, &item.Error)
if err != nil {
return nil, false, err
}
@@ -1743,6 +1777,40 @@ func (s *Store) ListSSHSessionsFiltered(limit int, offset int, query string, sta
return items, false, nil
}
func (s *Store) CountSSHSessionsFiltered(query string, status string) (int, error) {
var whereParts []string
var args []any
var sqlQuery string
var like string
var total int
var err error
query = strings.TrimSpace(query)
status = strings.ToLower(strings.TrimSpace(status))
if query != "" {
like = "%" + query + "%"
whereParts = append(whereParts, "(ss.public_id LIKE ? OR COALESCE(p.public_id, '') LIKE ? OR COALESCE(p.name, '') LIKE ? OR COALESCE(s.public_id, '') LIKE ? OR COALESCE(s.name, '') LIKE ? OR COALESCE(u.public_id, '') LIKE ? OR ss.username LIKE ? OR ss.remote_username LIKE ? OR ss.host LIKE ? OR ss.auth_method LIKE ? OR ss.status LIKE ? OR ss.host_key_fingerprint LIKE ? OR ss.error LIKE ?)")
args = append(args, like, like, like, like, like, like, like, like, like, like, like, like, like)
}
if status != "" {
whereParts = append(whereParts, "ss.status = ?")
args = append(args, status)
}
sqlQuery = `SELECT COUNT(*)
FROM ssh_sessions ss
LEFT JOIN ssh_access_profiles p ON p.id = ss.profile_id
LEFT JOIN ssh_servers s ON s.id = ss.server_id
LEFT JOIN users u ON u.id = ss.user_id`
if len(whereParts) > 0 {
sqlQuery += "\n\t\tWHERE " + strings.Join(whereParts, " AND ")
}
err = s.QueryRow(sqlQuery, args...).Scan(&total)
if err != nil {
return 0, err
}
return total, nil
}
func (s *Store) UpdateSSHSessionStatus(id string, status string, hostKeyFingerprint string, connectedAt int64, endedAt int64, errorText string) error {
var err error
@@ -1958,6 +2026,49 @@ func (s *Store) ListSSHFileTransfersForUserFiltered(userID string, limit int, of
return scanSSHFileTransferRows(rows, limit)
}
func (s *Store) CountSSHFileTransfersForUserFiltered(userID string, query string, status string, sessionID string) (int, error) {
var whereParts []string
var args []any
var sqlQuery string
var like string
var total int
var err error
query = strings.TrimSpace(query)
status = strings.ToLower(strings.TrimSpace(status))
sessionID = strings.TrimSpace(sessionID)
whereParts = append(whereParts, "u.public_id = ?")
args = append(args, strings.TrimSpace(userID))
if sessionID != "" {
whereParts = append(whereParts, "sess.public_id = ?")
args = append(args, sessionID)
}
if query != "" {
like = "%" + query + "%"
whereParts = append(whereParts, "(ft.public_id LIKE ? OR COALESCE(sess.public_id, '') LIKE ? OR COALESCE(p.public_id, '') LIKE ? OR COALESCE(s.public_id, '') LIKE ? OR ft.server_name LIKE ? OR ft.username LIKE ? OR ft.remote_username LIKE ? OR ft.operation LIKE ? OR COALESCE(tsess.public_id, '') LIKE ? OR COALESCE(ts.public_id, '') LIKE ? OR ft.target_server_name LIKE ? OR ft.target_dir LIKE ? OR ft.paths_json LIKE ? OR ft.status LIKE ? OR ft.error LIKE ?)")
args = append(args, like, like, like, like, like, like, like, like, like, like, like, like, like, like, like)
}
if status != "" {
whereParts = append(whereParts, "ft.status = ?")
args = append(args, status)
}
sqlQuery = fmt.Sprintf(`SELECT COUNT(*)
FROM ssh_file_transfers ft
LEFT JOIN ssh_sessions sess ON sess.id = ft.session_id
LEFT JOIN users u ON u.id = ft.user_id
LEFT JOIN ssh_access_profiles p ON p.id = ft.profile_id
LEFT JOIN ssh_servers s ON s.id = ft.server_id
LEFT JOIN ssh_sessions ssess ON ssess.id = ft.source_session_id
LEFT JOIN ssh_sessions tsess ON tsess.id = ft.target_session_id
LEFT JOIN ssh_servers ts ON ts.id = ft.target_server_id
WHERE %s`, strings.Join(whereParts, " AND "))
err = s.QueryRow(sqlQuery, args...).Scan(&total)
if err != nil {
return 0, err
}
return total, nil
}
func (s *Store) ListSSHFileTransfersFiltered(limit int, offset int, query string, status string, sessionID string, userID string) ([]models.SSHFileTransfer, bool, error) {
var rows *sql.Rows
var whereParts []string
@@ -2010,6 +2121,54 @@ func (s *Store) ListSSHFileTransfersFiltered(limit int, offset int, query string
return scanSSHFileTransferRows(rows, limit)
}
func (s *Store) CountSSHFileTransfersFiltered(query string, status string, sessionID string, userID string) (int, error) {
var whereParts []string
var args []any
var sqlQuery string
var like string
var total int
var err error
query = strings.TrimSpace(query)
status = strings.ToLower(strings.TrimSpace(status))
sessionID = strings.TrimSpace(sessionID)
userID = strings.TrimSpace(userID)
if sessionID != "" {
whereParts = append(whereParts, "sess.public_id = ?")
args = append(args, sessionID)
}
if userID != "" {
whereParts = append(whereParts, "u.public_id = ?")
args = append(args, userID)
}
if query != "" {
like = "%" + query + "%"
whereParts = append(whereParts, "(ft.public_id LIKE ? OR COALESCE(sess.public_id, '') LIKE ? OR COALESCE(u.public_id, '') LIKE ? OR ft.username LIKE ? OR COALESCE(p.public_id, '') LIKE ? OR COALESCE(s.public_id, '') LIKE ? OR ft.server_name LIKE ? OR ft.remote_username LIKE ? OR ft.operation LIKE ? OR COALESCE(tsess.public_id, '') LIKE ? OR COALESCE(ts.public_id, '') LIKE ? OR ft.target_server_name LIKE ? OR ft.target_dir LIKE ? OR ft.paths_json LIKE ? OR ft.status LIKE ? OR ft.error LIKE ?)")
args = append(args, like, like, like, like, like, like, like, like, like, like, like, like, like, like, like, like)
}
if status != "" {
whereParts = append(whereParts, "ft.status = ?")
args = append(args, status)
}
sqlQuery = `SELECT COUNT(*)
FROM ssh_file_transfers ft
LEFT JOIN ssh_sessions sess ON sess.id = ft.session_id
LEFT JOIN users u ON u.id = ft.user_id
LEFT JOIN ssh_access_profiles p ON p.id = ft.profile_id
LEFT JOIN ssh_servers s ON s.id = ft.server_id
LEFT JOIN ssh_sessions ssess ON ssess.id = ft.source_session_id
LEFT JOIN ssh_sessions tsess ON tsess.id = ft.target_session_id
LEFT JOIN ssh_servers ts ON ts.id = ft.target_server_id`
if len(whereParts) > 0 {
sqlQuery += "\n\t\tWHERE " + strings.Join(whereParts, " AND ")
}
err = s.QueryRow(sqlQuery, args...).Scan(&total)
if err != nil {
return 0, err
}
return total, nil
}
func createSSHSecretTx(tx *sql.Tx, item models.SSHSecret) (models.SSHSecret, error) {
var err error
var now int64
@@ -1,5 +1,6 @@
package handlers
import "context"
import "errors"
import "io"
import "strings"
@@ -23,6 +24,7 @@ type sshActiveSession struct {
closeCode SSHSessionCloseCode
closeReason string
closed bool
dialCancel context.CancelFunc
}
type SSHSessionCloseCode string
@@ -422,11 +424,25 @@ func (s *sshActiveSession) CloseCode() SSHSessionCloseCode {
return code
}
func (s *sshActiveSession) SetDialCancel(cancel context.CancelFunc) {
var shouldCancel bool
if s == nil { return }
s.mu.Lock()
s.dialCancel = cancel
shouldCancel = s.closed
s.mu.Unlock()
if shouldCancel && cancel != nil {
cancel()
}
}
func (s *sshActiveSession) RequestCloseWithReason(code SSHSessionCloseCode, reason string) {
var ws *websocket.Conn
var client *ssh.Client
var session *ssh.Session
var stdin io.WriteCloser
var dialCancel context.CancelFunc
if s == nil { return }
@@ -450,8 +466,10 @@ func (s *sshActiveSession) RequestCloseWithReason(code SSHSessionCloseCode, reas
client = s.client
session = s.session
stdin = s.stdin
dialCancel = s.dialCancel
s.mu.Unlock()
if dialCancel != nil { dialCancel() }
if stdin != nil { _ = stdin.Close() }
if session != nil { _ = session.Close() }
if client != nil { _ = client.Close() }
+128 -9
View File
@@ -1,6 +1,7 @@
package handlers
import codit_logger "codit/logger"
import "context"
import "database/sql"
import "encoding/base64"
import "errors"
@@ -121,13 +122,22 @@ type sshSessionListResponse struct {
Items []models.SSHSession `json:"items"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Total int `json:"total"`
HasMore bool `json:"has_more"`
}
type sshConnDialResult struct {
conn ssh.Conn
chans <-chan ssh.NewChannel
reqs <-chan *ssh.Request
err error
}
type sshFileTransferListResponse struct {
Items []models.SSHFileTransfer `json:"items"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Total int `json:"total"`
HasMore bool `json:"has_more"`
}
@@ -1890,6 +1900,7 @@ func (api *API) ListSSHSessionsForSelf(w http.ResponseWriter, r *http.Request, _
var status string
var items []models.SSHSession
var hasMore bool
var total int
var err error
user, ok = middleware.UserFromContext(r.Context())
@@ -1906,8 +1917,13 @@ func (api *API) ListSSHSessionsForSelf(w http.ResponseWriter, r *http.Request, _
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
total, err = api.store(r).CountSSHSessionsForUserFiltered(user.ID, query, status)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
api.setSSHSessionsTranscriptAvailability(items)
WriteJSON(w, http.StatusOK, sshSessionListResponse{Items: items, Limit: limit, Offset: offset, HasMore: hasMore})
WriteJSON(w, http.StatusOK, sshSessionListResponse{Items: items, Limit: limit, Offset: offset, Total: total, HasMore: hasMore})
}
func (api *API) ListSSHSessionsAdmin(w http.ResponseWriter, r *http.Request, _ map[string]string) {
@@ -1917,6 +1933,7 @@ func (api *API) ListSSHSessionsAdmin(w http.ResponseWriter, r *http.Request, _ m
var status string
var items []models.SSHSession
var hasMore bool
var total int
var err error
if !api.requireAdmin(w, r) {
@@ -1931,8 +1948,13 @@ func (api *API) ListSSHSessionsAdmin(w http.ResponseWriter, r *http.Request, _ m
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
total, err = api.store(r).CountSSHSessionsFiltered(query, status)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
api.setSSHSessionsTranscriptAvailability(items)
WriteJSON(w, http.StatusOK, sshSessionListResponse{Items: items, Limit: limit, Offset: offset, HasMore: hasMore})
WriteJSON(w, http.StatusOK, sshSessionListResponse{Items: items, Limit: limit, Offset: offset, Total: total, HasMore: hasMore})
}
func (api *API) ListSSHFileTransfersForSelf(w http.ResponseWriter, r *http.Request, _ map[string]string) {
@@ -1945,6 +1967,7 @@ func (api *API) ListSSHFileTransfersForSelf(w http.ResponseWriter, r *http.Reque
var sessionID string
var items []models.SSHFileTransfer
var hasMore bool
var total int
var err error
user, ok = middleware.UserFromContext(r.Context())
@@ -1962,7 +1985,12 @@ func (api *API) ListSSHFileTransfersForSelf(w http.ResponseWriter, r *http.Reque
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
WriteJSON(w, http.StatusOK, sshFileTransferListResponse{Items: items, Limit: limit, Offset: offset, HasMore: hasMore})
total, err = api.store(r).CountSSHFileTransfersForUserFiltered(user.ID, query, status, sessionID)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
WriteJSON(w, http.StatusOK, sshFileTransferListResponse{Items: items, Limit: limit, Offset: offset, Total: total, HasMore: hasMore})
}
func (api *API) ListSSHFileTransfersAdmin(w http.ResponseWriter, r *http.Request, _ map[string]string) {
@@ -1974,6 +2002,7 @@ func (api *API) ListSSHFileTransfersAdmin(w http.ResponseWriter, r *http.Request
var userID string
var items []models.SSHFileTransfer
var hasMore bool
var total int
var err error
if !api.requireAdmin(w, r) {
@@ -1990,7 +2019,12 @@ func (api *API) ListSSHFileTransfersAdmin(w http.ResponseWriter, r *http.Request
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
WriteJSON(w, http.StatusOK, sshFileTransferListResponse{Items: items, Limit: limit, Offset: offset, HasMore: hasMore})
total, err = api.store(r).CountSSHFileTransfersFiltered(query, status, sessionID, userID)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
WriteJSON(w, http.StatusOK, sshFileTransferListResponse{Items: items, Limit: limit, Offset: offset, Total: total, HasMore: hasMore})
}
func (api *API) DisconnectSSHSessionForSelf(w http.ResponseWriter, r *http.Request, params map[string]string) {
@@ -2001,6 +2035,14 @@ func (api *API) DisconnectSSHSessionForSelf(w http.ResponseWriter, r *http.Reque
var err error
var now int64
// The frontend DISCONNECT button/icon is supposed trigger this function
// via an API call.
// The key flow is:
// RequestClose(id, UserDisconnect)
// RequestCloseWithReason
// dialCancel()
// dialCtx cancelled
// DialContext
user, ok = middleware.UserFromContext(r.Context())
if !ok || user.Disabled {
w.WriteHeader(http.StatusUnauthorized)
@@ -2640,6 +2682,7 @@ event_loop:
user.Username,
len(attachments))
break event_loop
case err = <-controlErrCh:
if closing { continue }
closing = true
@@ -2652,6 +2695,7 @@ event_loop:
user.Username,
err)
// an error is detected, request to close all attached streams.
for attachedID, attachment = range attachments {
closingSessionIDs[attachedID] = true
if api.SSHSessionRegistry != nil {
@@ -2662,15 +2706,15 @@ event_loop:
default: // make it non-blocking
}
}
if len(attachments) == 0 {
break event_loop
}
if len(attachments) == 0 { break event_loop }
continue
case attachmentDone = <-attachmentDoneCh:
if attachments[attachmentDone.SessionID] == attachmentDone.Attachment {
delete(attachments, attachmentDone.SessionID)
}
if closing && len(attachments) == 0 { break event_loop }
case msg = <-controlCh:
if closing { continue }
if msg.Type == "attach" {
@@ -2905,6 +2949,13 @@ func (api *API) runSSHSessionStream(store *db.Store, user *models.User, sessionI
var runtimeStartedAt time.Time
var pinHostKeyErr error
var pinHostKeyStartedAt time.Time
var dialCtx context.Context
var dialCancel context.CancelFunc
var dialConn net.Conn
var dialAddr string
var sshConnResultCh chan sshConnDialResult
var sshConnDialRes sshConnDialResult
var reportDialCancelledBySession func() bool
runtimeStartedAt = time.Now()
defer func() {
@@ -3003,14 +3054,77 @@ func (api *API) runSSHSessionStream(store *db.Store, user *models.User, sessionI
if sendStatus != nil {
sendStatus(sshSessionStreamMessage{Type: "status", Status: "connecting", Message: "connecting"})
}
reportDialCancelledBySession = func() bool {
closeReason = runtimeSession.CloseReason()
closeCode = runtimeSession.CloseCode()
if closeReason == "" {
return false
}
now = time.Now().UTC().Unix()
_ = store.UpdateSSHSessionStatus(sessionItem.ID, "closed", "", 0, now, closeReason)
api.logSSHSessionClosed(sessionItem, &profile, user, "closed", closeReason, 0, now)
if sendStatus != nil && !isSSHSessionTransportCloseCode(closeCode) {
sendStatus(sshSessionStreamMessage{Type: "status", Status: "closed", Message: closeReason})
}
return true
}
dialAddr = net.JoinHostPort(profile.Server.Host, fmt.Sprintf("%d", profile.Server.Port))
sshConfig = &ssh.ClientConfig{
User: profile.RemoteUsername,
Auth: authMethods,
HostKeyCallback: sshHostKeyCallback(hostKeys, &connectedFingerprint, &connectedHostKey, prepared.PinHostKeyOnFirstUse),
Timeout: 15 * time.Second,
}
sshClient, err = ssh.Dial("tcp", net.JoinHostPort(profile.Server.Host, fmt.Sprintf("%d", profile.Server.Port)), sshConfig)
dialCtx, dialCancel = context.WithTimeout(context.Background(), 15*time.Second)
runtimeSession.SetDialCancel(dialCancel)
defer dialCancel()
dialConn, err = (&net.Dialer{}).DialContext(dialCtx, "tcp", dialAddr)
if err != nil {
if reportDialCancelledBySession() {
return
}
api.logSSHSessionConnectFailure(sessionItem, &profile, user, "dial", err)
if sendStatus != nil {
sendStatus(sshSessionStreamMessage{Type: "status", Status: "error", Message: err.Error()})
}
now = time.Now().UTC().Unix()
_ = store.UpdateSSHSessionStatus(sessionItem.ID, "error", connectedFingerprint, 0, now, err.Error())
return
}
sshConnResultCh = make(chan sshConnDialResult, 1)
go func() {
var c ssh.Conn
var chans <-chan ssh.NewChannel
var reqs <-chan *ssh.Request
var connErr error
c, chans, reqs, connErr = ssh.NewClientConn(dialConn, dialAddr, sshConfig)
sshConnResultCh <- sshConnDialResult{conn: c, chans: chans, reqs: reqs, err: connErr}
}()
select {
case sshConnDialRes = <-sshConnResultCh:
if sshConnDialRes.err != nil {
err = sshConnDialRes.err
if reportDialCancelledBySession() {
return
}
api.logSSHSessionConnectFailure(sessionItem, &profile, user, "dial", err)
if sendStatus != nil {
sendStatus(sshSessionStreamMessage{Type: "status", Status: "error", Message: err.Error()})
}
now = time.Now().UTC().Unix()
_ = store.UpdateSSHSessionStatus(sessionItem.ID, "error", connectedFingerprint, 0, now, err.Error())
return
}
sshClient = ssh.NewClient(sshConnDialRes.conn, sshConnDialRes.chans, sshConnDialRes.reqs)
case <-dialCtx.Done():
_ = dialConn.Close()
sshConnDialRes = <-sshConnResultCh
if sshConnDialRes.conn != nil {
_ = sshConnDialRes.conn.Close()
}
if reportDialCancelledBySession() {
return
}
err = fmt.Errorf("connection timed out")
api.logSSHSessionConnectFailure(sessionItem, &profile, user, "dial", err)
if sendStatus != nil {
sendStatus(sshSessionStreamMessage{Type: "status", Status: "error", Message: err.Error()})
@@ -3652,9 +3766,14 @@ func (api *API) readSSHSessionWebsocket(ws *websocket.Conn, inputCh chan<- sshSe
msg = sshSessionStreamMessage{}
err = websocket.JSON.Receive(ws, &msg)
if err != nil {
// if a websocket error is detected, write to the error channel
// serveSSHWorkspaceStream() which started this gorountine function
// received this channel message and calls RequestClose(id, websocketCloseCode)
// to break attached streams.
errCh <- err
return
}
select {
case inputCh <- msg:
default:
+1
View File
@@ -651,6 +651,7 @@ type SSHAccessProfileTarget struct {
type SSHSession struct {
ID string `json:"id"`
ProfileID string `json:"profile_id"`
ProfileName string `json:"profile_name"`
ServerID string `json:"server_id"`
ServerName string `json:"server_name"`
UserID string `json:"user_id"`
+3
View File
@@ -1125,6 +1125,7 @@ export type SSHPrincipalGrantUpsertPayload = {
export interface SSHSession {
id: string
profile_id: string
profile_name: string
server_id: string
server_name: string
user_id: string
@@ -1152,6 +1153,7 @@ export interface SSHSessionListResponse {
items: SSHSession[]
limit: number
offset: number
total: number
has_more: boolean
}
@@ -1238,6 +1240,7 @@ export interface SSHFileTransferListResponse {
items: SSHFileTransfer[]
limit: number
offset: number
total: number
has_more: boolean
}
+4 -1
View File
@@ -10,12 +10,15 @@ type PageAlertProps = {
sx?: SxProps<Theme>
}
const mySx: SxProps<Theme> = { mr: 1, ml: 0.5 }
export default function PageAlert(props: PageAlertProps) {
const sx: SxProps<Theme> = (props.sx ? [mySx, props.sx] : mySx) as SxProps<Theme>
const content: ReactNode = props.children || props.message
if (!content) return null
return (
<Alert severity={props.severity || 'error'} onClose={props.onClose} sx={props.sx}>
<Alert severity={props.severity || 'error'} onClose={props.onClose} sx={sx}>
{content}
</Alert>
)
@@ -0,0 +1,95 @@
import TextField from '@mui/material/TextField'
import { SxProps, Theme } from '@mui/material/styles'
import { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react'
import Autocomplete from './Autocomplete'
type PageSizeAutocompleteProps = {
value: number
onChange: (value: number) => void
label?: string
placeholder?: string
presets?: number[]
min?: number
max?: number
size?: 'small' | 'medium'
sx?: SxProps<Theme>
}
function clampPageSize(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value))
}
function parsePageSize(value: string | number | null, min: number, max: number): number | null {
let text: string
let parsed: number
if (value === null) { return null }
text = String(value).trim()
if (!/^\d+$/.test(text)) { return null }
parsed = Number(text)
if (!Number.isFinite(parsed)) { return null }
return clampPageSize(parsed, min, max)
}
export default function PageSizeAutocomplete(props: PageSizeAutocompleteProps) {
const value: number = props.value
const onChange: (value: number) => void = props.onChange
const label: string | undefined = props.placeholder ? undefined : (props.label || 'Page Size')
const presets: number[] = props.presets || [10, 25, 50]
const min: number = props.min || 1
const max: number = props.max || 200
const size: 'small' | 'medium' = props.size || 'small'
const [inputValue, setInputValue]: [string, Dispatch<SetStateAction<string>>] = useState<string>(String(value))
useEffect(() => {
setInputValue(String(value))
}, [value])
const commitValue = useCallback((raw: string | number | null) => {
const nextValue: number | null = parsePageSize(raw, min, max)
if (nextValue === null) {
setInputValue(String(value))
return
}
setInputValue(String(nextValue))
if (nextValue !== value) {
onChange(nextValue)
}
}, [max, min, onChange, value])
return (
<Autocomplete<number, false, true, true>
freeSolo
disableClearable
forcePopupIcon={false}
selectOnFocus
clearOnBlur={false}
options={presets}
value={value}
inputValue={inputValue}
onInputChange={(_, value, reason) => {
if (reason === 'input' || reason === 'clear') {
setInputValue(value)
}
}}
onChange={(_, value) => commitValue(value)}
getOptionLabel={(option) => String(option)}
size={size}
sx={props.sx}
renderInput={(params) => (
<TextField
{...params}
label={label}
onBlur={() => commitValue(inputValue)}
inputProps={{
...params.inputProps,
inputMode: 'numeric',
pattern: '[0-9]*',
placeholder: props.placeholder,
}}
/>
)}
/>
)
}
@@ -0,0 +1,155 @@
import Box from '@mui/material/Box'
import IconButton from '@mui/material/IconButton'
import TextField from '@mui/material/TextField'
import Tooltip from '@mui/material/Tooltip'
import Typography from '@mui/material/Typography'
import FirstPageIcon from '@mui/icons-material/FirstPage'
import LastPageIcon from '@mui/icons-material/LastPage'
import NavigateBeforeIcon from '@mui/icons-material/NavigateBefore'
import NavigateNextIcon from '@mui/icons-material/NavigateNext'
import { SxProps, Theme } from '@mui/material/styles'
import { ChangeEvent, KeyboardEvent, useEffect, useMemo, useState } from 'react'
type PaginationControlsProps = {
offset: number
limit: number
total: number
loading: boolean
onOffsetChange: (offset: number) => void
onLimitChange: (limit: number) => void
sx?: SxProps<Theme>
}
function clampPage(value: number, lastPage: number): number {
if (value < 1) { return 1 }
if (value > lastPage) { return lastPage }
return value
}
export default function PaginationControls(props: PaginationControlsProps) {
const lastPage: number = useMemo(() => {
if (props.limit <= 0 || props.total <= 0) { return 1 }
return Math.max(1, Math.ceil(props.total / props.limit))
}, [props.limit, props.total])
const currentPage: number = useMemo(() => {
if (props.limit <= 0) { return 1 }
return clampPage(Math.floor(props.offset / props.limit) + 1, lastPage)
}, [lastPage, props.limit, props.offset])
const [pageInput, setPageInput] = useState<string>(String(currentPage))
const [sizeInput, setSizeInput] = useState<string>(String(props.limit))
const canGoBack: boolean = !props.loading && currentPage > 1
const canGoForward: boolean = !props.loading && currentPage < lastPage
const startItem: number = props.total > 0 ? props.offset + 1 : 0
const endItem: number = props.total > 0 ? Math.min(props.offset + props.limit, props.total) : 0
useEffect(() => {
setPageInput(String(currentPage))
}, [currentPage])
useEffect(() => {
setSizeInput(String(props.limit))
}, [props.limit])
const commitSizeInput = () => {
let value: number
value = Number(sizeInput.trim())
if (!Number.isInteger(value) || value < 1) {
setSizeInput(String(props.limit))
return
}
value = Math.min(200, Math.max(1, value))
setSizeInput(String(value))
if (value !== props.limit) {
props.onLimitChange(value)
}
}
const goToPage = (page: number) => {
const nextPage: number = clampPage(page, lastPage)
const nextOffset: number = (nextPage - 1) * props.limit
if (props.loading || nextOffset === props.offset) { return }
props.onOffsetChange(nextOffset)
}
const commitPageInput = () => {
let value: number
value = Number(pageInput.trim())
if (!Number.isInteger(value)) {
setPageInput(String(currentPage))
return
}
goToPage(value)
}
return (
<Box sx={[{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 0.5, flexWrap: 'wrap', flex: '0 0 auto' }, ...(Array.isArray(props.sx) ? props.sx : props.sx ? [props.sx] : [])] as SxProps<Theme>}>
<Typography variant="caption" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>
{startItem}-{endItem} / {props.total}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<TextField
size="small"
label="Size"
value={sizeInput}
onChange={(event: ChangeEvent<HTMLInputElement>) => setSizeInput(event.target.value)}
onBlur={commitSizeInput}
onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') { commitSizeInput() }
}}
disabled={props.loading}
inputProps={{ inputMode: 'numeric', pattern: '[0-9]*', sx: { textAlign: 'center', p: 0.5 } }}
sx={{ width: 65, mr: 0.75 }}
/>
<Tooltip title="First page">
<span>
<IconButton size="small" onClick={() => goToPage(1)} disabled={!canGoBack}>
<FirstPageIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
<Tooltip title="Previous page">
<span>
<IconButton size="small" onClick={() => goToPage(currentPage - 1)} disabled={!canGoBack}>
<NavigateBeforeIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
<TextField
size="small"
label="Page"
value={pageInput}
onChange={(event: ChangeEvent<HTMLInputElement>) => setPageInput(event.target.value)}
onBlur={commitPageInput}
onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
commitPageInput()
}
}}
disabled={props.loading || props.total <= 0}
inputProps={{ inputMode: 'numeric', pattern: '[0-9]*', sx: { textAlign: 'center', p: 0.5 } }}
sx={{ width: 65 }}
/>
<Typography variant="caption" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>
/ {lastPage}
</Typography>
<Tooltip title="Next page">
<span>
<IconButton size="small" onClick={() => goToPage(currentPage + 1)} disabled={!canGoForward}>
<NavigateNextIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
<Tooltip title="Last page">
<span>
<IconButton size="small" onClick={() => goToPage(lastPage)} disabled={!canGoForward}>
<LastPageIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</Box>
</Box>
)
}
@@ -3,7 +3,6 @@ import Button from '@mui/material/Button'
import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle'
import MenuItem from '@mui/material/MenuItem'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import Autocomplete from './Autocomplete'
@@ -27,6 +26,7 @@ type SSHSessionCreateDialogProps = {
export default function SSHSessionCreateDialog(props: SSHSessionCreateDialogProps) {
const needsServerSelection: boolean = Boolean(props.selectedProfile?.server_target_type === 'group')
const selectedServer: SSHServer | null = props.serverOptions.find((item: SSHServer) => item.id === props.selectedServerID) || null
return (
<Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth>
@@ -41,20 +41,22 @@ export default function SSHSessionCreateDialog(props: SSHSessionCreateDialogProp
renderInput={(params) => <TextField {...params} label="SSH Access Profile" />}
/>
{needsServerSelection ? (
<TextField
select
label="SSH Server"
value={props.selectedServerID}
onChange={(event) => props.onSelectedServerIDChange(event.target.value)}
<Autocomplete<SSHServer, false, false, false>
options={props.serverOptions}
value={selectedServer}
onChange={(_, value) => props.onSelectedServerIDChange(value?.id || '')}
getOptionLabel={(item) => `${item.name} (${item.host}:${item.port})`}
isOptionEqualToValue={(option, value) => option.id === value.id}
disabled={props.busy || props.serversLoading || props.serverOptions.length === 0}
loading={props.serversLoading}
renderInput={(params) => (
<TextField
{...params}
label="SSH Server"
helperText={props.serversLoading ? 'Loading servers...' : 'Select a target server from this server group.'}
>
{props.serverOptions.map((item: SSHServer) => (
<MenuItem key={item.id} value={item.id}>
{item.name} ({item.host}:{item.port})
</MenuItem>
))}
</TextField>
/>
)}
/>
) : null}
{needsServerSelection && !props.serversLoading && props.serverOptions.length === 0 ? (
<Typography variant="body2" color="text.secondary">
+163 -129
View File
@@ -14,8 +14,12 @@ import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import useMediaQuery from '@mui/material/useMediaQuery'
import { useTheme } from '@mui/material/styles'
import MoreVertIcon from '@mui/icons-material/MoreVert'
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'
import KeyboardIcon from '@mui/icons-material/Keyboard'
import MoreVertIcon from '@mui/icons-material/MoreVert'
import PlayArrowIcon from '@mui/icons-material/PlayArrow'
import StopIcon from '@mui/icons-material/Stop'
import Tooltip from '@mui/material/Tooltip'
import { MutableRefObject, PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useBlocker, useLocation, useNavigate, useParams } from 'react-router-dom'
@@ -30,6 +34,8 @@ import SSHSessionFileUploadDialog from '../components/SSHSessionFileUploadDialog
import SSHTranscriptDialog from '../components/SSHTranscriptDialog'
import TintedPanel from '../components/TintedPanel'
import PageAlert from '../components/PageAlert'
import Autocomplete from '../components/Autocomplete'
import PaginationControls from '../components/PaginationControls'
import { api, SSHAccessProfile, SSHFileTransfer, SSHFileTransferListResponse, SSHServer, SSHSession, SSHSessionConnectResponse, SSHSessionListResponse } from '../api'
type SSHSessionStreamMessage = {
@@ -47,6 +53,11 @@ type SessionSummary = {
status: string
}
type TransferSessionFilterOption = {
id: string
label: string
}
type SessionStreamHandlers = {
onOutput: (data: Uint8Array) => void
onStatus: (message: SSHSessionStreamMessage) => void
@@ -665,34 +676,38 @@ function SessionTerminalPanel(props: SessionPanelProps) {
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap', px: 0.25 }}>
<Box sx={{ minWidth: 0, display: 'grid', flex: '1 1 320px' }}>
<Typography variant="subtitle2" >
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, minWidth: 0 }}>
<Typography variant="subtitle1" noWrap >
{sessionTitle}
</Typography>
{session ? (
<Typography variant="caption" color="text.secondary">
Host key: {session.host_key_fingerprint || '-'}
</Typography>
<Tooltip
title={
<Box sx={{ whiteSpace: 'pre', maxWidth: 'none' }}>
{`Profile: ${session.profile_name || session.profile_id || '-'}\nHost key: ${session.host_key_fingerprint || '-'}\nAuth: ${session.auth_method || '-'}\nUser: ${session.username || '-'}`}
</Box>
}
componentsProps={{ tooltip: { sx: { maxWidth: 'none' } } }}
>
<InfoOutlinedIcon sx={{ fontSize: 14, color: 'text.secondary', flexShrink: 0 }} />
</Tooltip>
) : null}
</Box>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Button
variant="outlined"
size="small"
onClick={() => void handleDuplicate()}
disabled={!canDuplicate}
>
Duplicate
</Button>
<Button
variant="outlined"
size="small"
onClick={() => void handleSessionConnectOrDisconnect()}
disabled={!canSessionAction}
>
{actionPending ? (canReconnect ? 'Connecting...' : 'Disconnecting...') : sessionConnectOrDisconnectLabel}
</Button>
<Tooltip title={actionPending ? (canReconnect ? 'Connecting...' : 'Disconnecting...') : sessionConnectOrDisconnectLabel}>
<span>
<IconButton size="small" onClick={() => void handleSessionConnectOrDisconnect()} disabled={!canSessionAction}>
{canReconnect ? <PlayArrowIcon fontSize="small" /> : <StopIcon fontSize="small" />}
</IconButton>
</span>
</Tooltip>
<Tooltip title="Duplicate">
<span>
<IconButton size="small" onClick={() => void handleDuplicate()} disabled={!canDuplicate}>
<ContentCopyIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
{openSessionIDs.length > 1 ? (
<Tooltip title={isSynced ? 'Leave sync group' : 'Join sync group — share keystrokes with other synced terminals'}>
<span>
@@ -702,9 +717,11 @@ function SessionTerminalPanel(props: SessionPanelProps) {
</span>
</Tooltip>
) : null}
<Tooltip title="More options">
<IconButton size="small" onClick={handleOpenActionMenu}>
<MoreVertIcon fontSize="small" />
</IconButton>
</Tooltip>
<Menu
anchorEl={actionMenuAnchor}
open={actionMenuOpen}
@@ -817,7 +834,7 @@ export default function SSHSessionPage() {
const [historyStatus, setHistoryStatus] = useState('')
const [historyLimit, setHistoryLimit] = useState(10)
const [historyOffset, setHistoryOffset] = useState(0)
const [historyHasMore, setHistoryHasMore] = useState(false)
const [historyTotal, setHistoryTotal] = useState(0)
const [historyOpen, setHistoryOpen] = useState(false)
const [transferItems, setTransferItems] = useState<SSHFileTransfer[]>([])
const [loadingTransfers, setLoadingTransfers] = useState(false)
@@ -827,7 +844,7 @@ export default function SSHSessionPage() {
const [transferSessionFilter, setTransferSessionFilter] = useState('')
const [transferLimit, setTransferLimit] = useState(25)
const [transferOffset, setTransferOffset] = useState(0)
const [transferHasMore, setTransferHasMore] = useState(false)
const [transferTotal, setTransferTotal] = useState(0)
const [transfersOpen, setTransfersOpen] = useState(false)
const [historyConnectItem, setHistoryConnectItem] = useState<SSHSession | null>(null)
const [historyConnectPassword, setHistoryConnectPassword] = useState('')
@@ -859,12 +876,41 @@ export default function SSHSessionPage() {
const workspaceRef = useRef<HTMLDivElement | null>(null)
const streamWSRef = useRef<WebSocket | null>(null)
const streamMessageQueueRef = useRef<SSHSessionStreamMessage[]>([])
const historyPagePendingRef = useRef(false)
const transferPagePendingRef = useRef(false)
const [streamWSCheck, setStreamWSCheck] = useState<number>(0)
const streamHandlersRef = useRef<Record<string, SessionStreamHandlers>>({})
const attachedSessionIDsRef = useRef<Record<string, boolean>>({})
const stackedLayout = useMediaQuery(theme.breakpoints.down('lg'))
const historyDialogMode: boolean = useMediaQuery(theme.breakpoints.down('md'))
const transferSessionOptions: TransferSessionFilterOption[] = useMemo(() => {
const items: TransferSessionFilterOption[] = []
const seen: Set<string> = new Set()
openSessionIDs.forEach((id: string) => {
seen.add(id)
items.push({ id, label: sessionSummaries[id]?.title || id })
})
transferItems.forEach((item: SSHFileTransfer) => {
let label: string
if (!item.session_id || seen.has(item.session_id)) { return }
label = item.server_name || item.server_id || item.session_id
if (item.remote_username) {
label = `${label} · ${item.remote_username}`
}
seen.add(item.session_id)
items.push({ id: item.session_id, label })
})
if (transferSessionFilter && !seen.has(transferSessionFilter)) {
items.push({ id: transferSessionFilter, label: transferSessionFilter })
}
return items
}, [openSessionIDs, sessionSummaries, transferItems, transferSessionFilter])
const selectedTransferSessionOption: TransferSessionFilterOption | null = useMemo(() => {
if (!transferSessionFilter) { return null }
return transferSessionOptions.find((item: TransferSessionFilterOption) => item.id === transferSessionFilter) || null
}, [transferSessionFilter, transferSessionOptions])
const rowCount = useMemo(() => {
if (stackedLayout) {
return Math.max(1, openSessionIDs.length)
@@ -947,14 +993,17 @@ export default function SSHSessionPage() {
status: historyStatus || undefined
}, signal ? { signal } : undefined)
setHistoryItems(Array.isArray(response.items) ? response.items : [])
setHistoryHasMore(Boolean(response.has_more))
setHistoryTotal(response.total || 0)
} catch (err) {
if (signal?.aborted) { return }
setHistoryError(err instanceof Error ? err.message : 'Failed to load SSH session history')
setHistoryItems([])
setHistoryHasMore(false)
setHistoryTotal(0)
} finally {
if (!signal?.aborted) setLoadingHistory(false)
if (!signal?.aborted) {
historyPagePendingRef.current = false
setLoadingHistory(false)
}
}
}, [historyLimit, historyOffset, historyQuery, historyStatus])
@@ -974,14 +1023,17 @@ export default function SSHSessionPage() {
session_id: transferSessionFilter || undefined
}, signal ? { signal } : undefined)
setTransferItems(Array.isArray(response.items) ? response.items : [])
setTransferHasMore(Boolean(response.has_more))
setTransferTotal(response.total || 0)
} catch (err) {
if (signal?.aborted) { return }
setTransferError(err instanceof Error ? err.message : 'Failed to load SSH file transfer history')
setTransferItems([])
setTransferHasMore(false)
setTransferTotal(0)
} finally {
if (!signal?.aborted) setLoadingTransfers(false)
if (!signal?.aborted) {
transferPagePendingRef.current = false
setLoadingTransfers(false)
}
}
}, [transferLimit, transferOffset, transferQuery, transferSessionFilter, transferStatus])
@@ -1246,23 +1298,19 @@ export default function SSHSessionPage() {
downloadTextFile(`ssh-session-${transcriptSession.id}.txt`, transcriptText)
}, [transcriptSession, transcriptText])
const handleHistoryPrev = useCallback(() => {
setHistoryOffset((prev) => Math.max(0, prev - historyLimit))
}, [historyLimit])
const handleHistoryOffsetChange = useCallback((offset: number) => {
if (historyPagePendingRef.current || loadingHistory) { return }
historyPagePendingRef.current = true
setLoadingHistory(true)
setHistoryOffset(Math.max(0, offset))
}, [loadingHistory])
const handleHistoryNext = useCallback(() => {
if (!historyHasMore) { return }
setHistoryOffset((prev) => prev + historyLimit)
}, [historyHasMore, historyLimit])
const handleTransferPrev = useCallback(() => {
setTransferOffset((prev: number) => Math.max(0, prev - transferLimit))
}, [transferLimit])
const handleTransferNext = useCallback(() => {
if (!transferHasMore) { return }
setTransferOffset((prev: number) => prev + transferLimit)
}, [transferHasMore, transferLimit])
const handleTransferOffsetChange = useCallback((offset: number) => {
if (transferPagePendingRef.current || loadingTransfers) { return }
transferPagePendingRef.current = true
setLoadingTransfers(true)
setTransferOffset(Math.max(0, offset))
}, [loadingTransfers])
const queueWorkspaceMessage = useCallback((message: SSHSessionStreamMessage) => {
const queueLimit: number = 500
@@ -1647,9 +1695,14 @@ export default function SSHSessionPage() {
}, [dividerThickness, minPanelHeight, rowCount, rowDragState])
const historyContent = (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, p: historyDialogMode ? 2 : 2, width: historyDialogMode ? 'auto' : 420, maxWidth: '100%', height: '100%', minHeight: 0, overflow: 'hidden' }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, p: historyDialogMode ? 2 : 2, width: historyDialogMode ? 'auto' : 'max(480px, 40vw)', maxWidth: '100%', height: '100%', minHeight: 0, overflow: 'hidden' }}>
<Box sx={{ display: 'grid', gap: 0.5, flex: '0 0 auto' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
<Typography variant="h6">Session History</Typography>
<Button variant="outlined" size="small" onClick={() => void loadHistory()} disabled={loadingHistory}>
{loadingHistory ? 'Refreshing...' : 'Refresh'}
</Button>
</Box>
<Typography variant="body2" color="text.secondary">
Recent SSH sessions for your account.
</Typography>
@@ -1660,6 +1713,8 @@ export default function SSHSessionPage() {
label="Search"
value={historyQuery}
onChange={(event) => {
historyPagePendingRef.current = true
setLoadingHistory(true)
setHistoryQuery(event.target.value)
setHistoryOffset(0)
}}
@@ -1671,6 +1726,8 @@ export default function SSHSessionPage() {
label="Status"
value={historyStatus}
onChange={(event) => {
historyPagePendingRef.current = true
setLoadingHistory(true)
setHistoryStatus(event.target.value)
setHistoryOffset(0)
}}
@@ -1683,27 +1740,9 @@ export default function SSHSessionPage() {
<MenuItem value="closed">closed</MenuItem>
<MenuItem value="error">error</MenuItem>
</TextField>
<TextField
select
size="small"
label="Page Size"
value={String(historyLimit)}
onChange={(event) => {
setHistoryLimit(Number(event.target.value) || 10)
setHistoryOffset(0)
}}
sx={{ minWidth: 130 }}
>
<MenuItem value="10">10</MenuItem>
<MenuItem value="25">25</MenuItem>
<MenuItem value="50">50</MenuItem>
</TextField>
<Button variant="outlined" size="small" onClick={() => void loadHistory()} disabled={loadingHistory}>
{loadingHistory ? 'Refreshing...' : 'Refresh'}
</Button>
</Box>
<PageAlert message={historyError} onClose={() => setHistoryError(null)} />
<Box sx={{ display: 'grid', gap: 0, minHeight: 0, flex: '1 1 auto', overflowY: 'auto' }}>
<Box sx={{ display: 'grid', gap: 0, alignContent: 'start', minHeight: 0, flex: '1 1 auto', overflowY: 'auto' }}>
{loadingHistory && !historyItems.length ? (
<Typography variant="body2" color="text.secondary">
Loading...
@@ -1717,6 +1756,7 @@ export default function SSHSessionPage() {
{historyItems.map((item) => {
const liveSummary: SessionSummary | undefined = sessionSummaries[item.id]
const liveStatus: string = liveSummary?.status || item.status || 'unknown'
const profileText: string = item.profile_name || item.profile_id || ''
const primaryActionLabel: string =
liveStatus === 'pending' || liveStatus === 'connecting' || liveStatus === 'connected'
? 'Open'
@@ -1741,8 +1781,13 @@ export default function SSHSessionPage() {
Started: {formatTimestamp(item.started_at)} · Ended: {formatTimestamp(item.ended_at)}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
Status: {liveStatus} · Auth: {item.auth_method} · Host key: {item.host_key_fingerprint || '-'}
Profile: {profileText || '-'} · Auth: {item.auth_method}
</Typography>
<Tooltip title={`Host key: ${item.host_key_fingerprint || '-'}`} componentsProps={{ tooltip: { sx: { maxWidth: 'none' } } }}>
<Typography variant="caption" color="text.secondary" noWrap sx={{ maxWidth: '100%', minWidth: 0 }}>
Host key: {item.host_key_fingerprint || '-'}
</Typography>
</Tooltip>
{item.error ? (
<Typography variant="caption" color="error" sx={{ wordBreak: 'break-word' }}>
{item.error}
@@ -1777,26 +1822,31 @@ export default function SSHSessionPage() {
)
})}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap', flex: '0 0 auto' }}>
<Typography variant="caption" color="text.secondary">
Showing {historyItems.length ? historyOffset + 1 : 0}-{historyOffset + historyItems.length}
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button variant="outlined" size="small" onClick={handleHistoryPrev} disabled={loadingHistory || historyOffset <= 0}>
Previous
</Button>
<Button variant="outlined" size="small" onClick={handleHistoryNext} disabled={loadingHistory || !historyHasMore}>
Next
</Button>
</Box>
</Box>
<PaginationControls
offset={historyOffset}
limit={historyLimit}
total={historyTotal}
loading={loadingHistory}
onOffsetChange={handleHistoryOffsetChange}
onLimitChange={(value: number) => {
historyPagePendingRef.current = true
setLoadingHistory(true)
setHistoryLimit(value)
setHistoryOffset(0)
}}
/>
</Box>
)
const transfersContent = (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, p: historyDialogMode ? 2 : 2, width: historyDialogMode ? 'auto' : 480, maxWidth: '100%', height: '100%', minHeight: 0, overflow: 'hidden' }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, p: historyDialogMode ? 2 : 2, width: historyDialogMode ? 'auto' : 'max(480px, 40vw)', maxWidth: '100%', height: '100%', minHeight: 0, overflow: 'hidden' }}>
<Box sx={{ display: 'grid', gap: 0.5, flex: '0 0 auto' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
<Typography variant="h6">Transfer History</Typography>
<Button variant="outlined" size="small" onClick={() => void loadTransfers()} disabled={loadingTransfers}>
{loadingTransfers ? 'Refreshing...' : 'Refresh'}
</Button>
</Box>
<Typography variant="body2" color="text.secondary">
Recent upload, download, and copy activity for your account.
</Typography>
@@ -1807,6 +1857,8 @@ export default function SSHSessionPage() {
label="Search"
value={transferQuery}
onChange={(event) => {
transferPagePendingRef.current = true
setLoadingTransfers(true)
setTransferQuery(event.target.value)
setTransferOffset(0)
}}
@@ -1818,6 +1870,8 @@ export default function SSHSessionPage() {
label="Status"
value={transferStatus}
onChange={(event) => {
transferPagePendingRef.current = true
setLoadingTransfers(true)
setTransferStatus(event.target.value)
setTransferOffset(0)
}}
@@ -1829,45 +1883,25 @@ export default function SSHSessionPage() {
<MenuItem value="cancelled">cancelled</MenuItem>
<MenuItem value="running">running</MenuItem>
</TextField>
<TextField
select
<Autocomplete<TransferSessionFilterOption, false, false, false>
size="small"
label="Session"
value={transferSessionFilter}
onChange={(event) => {
setTransferSessionFilter(event.target.value)
options={transferSessionOptions}
value={selectedTransferSessionOption}
onChange={(_, value) => {
transferPagePendingRef.current = true
setLoadingTransfers(true)
setTransferSessionFilter(value?.id || '')
setTransferOffset(0)
}}
sx={{ minWidth: 170 }}
>
<MenuItem value="">All sessions</MenuItem>
{openSessionIDs.map((id: string) => (
<MenuItem key={id} value={id}>
{sessionSummaries[id]?.title || id}
</MenuItem>
))}
</TextField>
<TextField
select
size="small"
label="Page Size"
value={String(transferLimit)}
onChange={(event) => {
setTransferLimit(Number(event.target.value) || 25)
setTransferOffset(0)
}}
sx={{ minWidth: 130 }}
>
<MenuItem value="10">10</MenuItem>
<MenuItem value="25">25</MenuItem>
<MenuItem value="50">50</MenuItem>
</TextField>
<Button variant="outlined" size="small" onClick={() => void loadTransfers()} disabled={loadingTransfers}>
{loadingTransfers ? 'Refreshing...' : 'Refresh'}
</Button>
openOnFocus
getOptionLabel={(item) => item.label}
isOptionEqualToValue={(option, value) => option.id === value.id}
sx={{ minWidth: 170, flex: '1 1 170px' }}
renderInput={(params) => <TextField {...params} label="Session" placeholder="All sessions" />}
/>
</Box>
<PageAlert message={transferError} onClose={() => setTransferError(null)} />
<Box sx={{ display: 'grid', gap: 0, minHeight: 0, flex: '1 1 auto', overflowY: 'auto' }}>
<Box sx={{ display: 'grid', gap: 0, alignContent: 'start', minHeight: 0, flex: '1 1 auto', overflowY: 'auto' }}>
{loadingTransfers && !transferItems.length ? (
<Typography variant="body2" color="text.secondary">
Loading...
@@ -1927,19 +1961,19 @@ export default function SSHSessionPage() {
)
})}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap', flex: '0 0 auto' }}>
<Typography variant="caption" color="text.secondary">
Showing {transferItems.length ? transferOffset + 1 : 0}-{transferOffset + transferItems.length}
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button variant="outlined" size="small" onClick={handleTransferPrev} disabled={loadingTransfers || transferOffset <= 0}>
Previous
</Button>
<Button variant="outlined" size="small" onClick={handleTransferNext} disabled={loadingTransfers || !transferHasMore}>
Next
</Button>
</Box>
</Box>
<PaginationControls
offset={transferOffset}
limit={transferLimit}
total={transferTotal}
loading={loadingTransfers}
onOffsetChange={handleTransferOffsetChange}
onLimitChange={(value: number) => {
transferPagePendingRef.current = true
setLoadingTransfers(true)
setTransferLimit(value)
setTransferOffset(0)
}}
/>
</Box>
)