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 item models.SSHSession
var err error 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 FROM ssh_sessions ss
LEFT JOIN ssh_access_profiles p ON p.id = ss.profile_id 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 ssh_servers s ON s.id = ss.server_id
LEFT JOIN users u ON u.id = ss.user_id LEFT JOIN users u ON u.id = ss.user_id
WHERE ss.public_id = ?`, strings.TrimSpace(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 { if err != nil {
return item, err return item, err
} }
@@ -1641,14 +1641,14 @@ func (s *Store) ListSSHSessionsForUserFiltered(userID string, limit int, offset
args = append(args, strings.TrimSpace(userID)) args = append(args, strings.TrimSpace(userID))
if query != "" { if query != "" {
like = "%" + 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 ?)") 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) args = append(args, like, like, like, like, like, like, like, like, like, like, like, like, like)
} }
if status != "" { if status != "" {
whereParts = append(whereParts, "ss.status = ?") whereParts = append(whereParts, "ss.status = ?")
args = append(args, 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 FROM ssh_sessions ss
LEFT JOIN ssh_access_profiles p ON p.id = ss.profile_id 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 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() defer rows.Close()
for rows.Next() { 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 { if err != nil {
return nil, false, err return nil, false, err
} }
@@ -1680,6 +1680,40 @@ func (s *Store) ListSSHSessionsForUserFiltered(userID string, limit int, offset
return items, false, nil 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) { func (s *Store) ListSSHSessionsFiltered(limit int, offset int, query string, status string) ([]models.SSHSession, bool, error) {
var rows *sql.Rows var rows *sql.Rows
var items []models.SSHSession 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)) status = strings.ToLower(strings.TrimSpace(status))
if query != "" { if query != "" {
like = "%" + 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 ?)") 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) args = append(args, like, like, like, like, like, like, like, like, like, like, like, like, like)
} }
if status != "" { if status != "" {
whereParts = append(whereParts, "ss.status = ?") whereParts = append(whereParts, "ss.status = ?")
args = append(args, 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 FROM ssh_sessions ss
LEFT JOIN ssh_access_profiles p ON p.id = ss.profile_id 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 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() defer rows.Close()
for rows.Next() { 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 { if err != nil {
return nil, false, err return nil, false, err
} }
@@ -1743,6 +1777,40 @@ func (s *Store) ListSSHSessionsFiltered(limit int, offset int, query string, sta
return items, false, nil 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 { func (s *Store) UpdateSSHSessionStatus(id string, status string, hostKeyFingerprint string, connectedAt int64, endedAt int64, errorText string) error {
var err error var err error
@@ -1958,6 +2026,49 @@ func (s *Store) ListSSHFileTransfersForUserFiltered(userID string, limit int, of
return scanSSHFileTransferRows(rows, limit) 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) { 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 rows *sql.Rows
var whereParts []string var whereParts []string
@@ -2010,6 +2121,54 @@ func (s *Store) ListSSHFileTransfersFiltered(limit int, offset int, query string
return scanSSHFileTransferRows(rows, limit) 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) { func createSSHSecretTx(tx *sql.Tx, item models.SSHSecret) (models.SSHSecret, error) {
var err error var err error
var now int64 var now int64
@@ -1,5 +1,6 @@
package handlers package handlers
import "context"
import "errors" import "errors"
import "io" import "io"
import "strings" import "strings"
@@ -23,6 +24,7 @@ type sshActiveSession struct {
closeCode SSHSessionCloseCode closeCode SSHSessionCloseCode
closeReason string closeReason string
closed bool closed bool
dialCancel context.CancelFunc
} }
type SSHSessionCloseCode string type SSHSessionCloseCode string
@@ -422,11 +424,25 @@ func (s *sshActiveSession) CloseCode() SSHSessionCloseCode {
return code 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) { func (s *sshActiveSession) RequestCloseWithReason(code SSHSessionCloseCode, reason string) {
var ws *websocket.Conn var ws *websocket.Conn
var client *ssh.Client var client *ssh.Client
var session *ssh.Session var session *ssh.Session
var stdin io.WriteCloser var stdin io.WriteCloser
var dialCancel context.CancelFunc
if s == nil { return } if s == nil { return }
@@ -450,8 +466,10 @@ func (s *sshActiveSession) RequestCloseWithReason(code SSHSessionCloseCode, reas
client = s.client client = s.client
session = s.session session = s.session
stdin = s.stdin stdin = s.stdin
dialCancel = s.dialCancel
s.mu.Unlock() s.mu.Unlock()
if dialCancel != nil { dialCancel() }
if stdin != nil { _ = stdin.Close() } if stdin != nil { _ = stdin.Close() }
if session != nil { _ = session.Close() } if session != nil { _ = session.Close() }
if client != nil { _ = client.Close() } if client != nil { _ = client.Close() }
+138 -19
View File
@@ -1,6 +1,7 @@
package handlers package handlers
import codit_logger "codit/logger" import codit_logger "codit/logger"
import "context"
import "database/sql" import "database/sql"
import "encoding/base64" import "encoding/base64"
import "errors" import "errors"
@@ -121,13 +122,22 @@ type sshSessionListResponse struct {
Items []models.SSHSession `json:"items"` Items []models.SSHSession `json:"items"`
Limit int `json:"limit"` Limit int `json:"limit"`
Offset int `json:"offset"` Offset int `json:"offset"`
Total int `json:"total"`
HasMore bool `json:"has_more"` HasMore bool `json:"has_more"`
} }
type sshConnDialResult struct {
conn ssh.Conn
chans <-chan ssh.NewChannel
reqs <-chan *ssh.Request
err error
}
type sshFileTransferListResponse struct { type sshFileTransferListResponse struct {
Items []models.SSHFileTransfer `json:"items"` Items []models.SSHFileTransfer `json:"items"`
Limit int `json:"limit"` Limit int `json:"limit"`
Offset int `json:"offset"` Offset int `json:"offset"`
Total int `json:"total"`
HasMore bool `json:"has_more"` HasMore bool `json:"has_more"`
} }
@@ -1890,6 +1900,7 @@ func (api *API) ListSSHSessionsForSelf(w http.ResponseWriter, r *http.Request, _
var status string var status string
var items []models.SSHSession var items []models.SSHSession
var hasMore bool var hasMore bool
var total int
var err error var err error
user, ok = middleware.UserFromContext(r.Context()) 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()) WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return 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) 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) { 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 status string
var items []models.SSHSession var items []models.SSHSession
var hasMore bool var hasMore bool
var total int
var err error var err error
if !api.requireAdmin(w, r) { 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()) WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return return
} }
total, err = api.store(r).CountSSHSessionsFiltered(query, status)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
api.setSSHSessionsTranscriptAvailability(items) 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) { 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 sessionID string
var items []models.SSHFileTransfer var items []models.SSHFileTransfer
var hasMore bool var hasMore bool
var total int
var err error var err error
user, ok = middleware.UserFromContext(r.Context()) 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()) WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return 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) { 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 userID string
var items []models.SSHFileTransfer var items []models.SSHFileTransfer
var hasMore bool var hasMore bool
var total int
var err error var err error
if !api.requireAdmin(w, r) { 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()) WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return 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) { 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 err error
var now int64 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()) user, ok = middleware.UserFromContext(r.Context())
if !ok || user.Disabled { if !ok || user.Disabled {
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
@@ -2640,6 +2682,7 @@ event_loop:
user.Username, user.Username,
len(attachments)) len(attachments))
break event_loop break event_loop
case err = <-controlErrCh: case err = <-controlErrCh:
if closing { continue } if closing { continue }
closing = true closing = true
@@ -2652,25 +2695,26 @@ event_loop:
user.Username, user.Username,
err) err)
// an error is detected, request to close all attached streams.
for attachedID, attachment = range attachments { for attachedID, attachment = range attachments {
closingSessionIDs[attachedID] = true closingSessionIDs[attachedID] = true
if api.SSHSessionRegistry != nil { if api.SSHSessionRegistry != nil {
api.SSHSessionRegistry.RequestClose(attachedID, websocketCloseCode) api.SSHSessionRegistry.RequestClose(attachedID, websocketCloseCode)
} }
select { select {
case attachment.InputErrCh <- err: case attachment.InputErrCh <- err:
default: // make it non-blocking default: // make it non-blocking
} }
} }
if len(attachments) == 0 { if len(attachments) == 0 { break event_loop }
break event_loop
}
continue continue
case attachmentDone = <-attachmentDoneCh: case attachmentDone = <-attachmentDoneCh:
if attachments[attachmentDone.SessionID] == attachmentDone.Attachment { if attachments[attachmentDone.SessionID] == attachmentDone.Attachment {
delete(attachments, attachmentDone.SessionID) delete(attachments, attachmentDone.SessionID)
} }
if closing && len(attachments) == 0 { break event_loop } if closing && len(attachments) == 0 { break event_loop }
case msg = <-controlCh: case msg = <-controlCh:
if closing { continue } if closing { continue }
if msg.Type == "attach" { if msg.Type == "attach" {
@@ -2905,6 +2949,13 @@ func (api *API) runSSHSessionStream(store *db.Store, user *models.User, sessionI
var runtimeStartedAt time.Time var runtimeStartedAt time.Time
var pinHostKeyErr error var pinHostKeyErr error
var pinHostKeyStartedAt time.Time 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() runtimeStartedAt = time.Now()
defer func() { defer func() {
@@ -3003,14 +3054,77 @@ func (api *API) runSSHSessionStream(store *db.Store, user *models.User, sessionI
if sendStatus != nil { if sendStatus != nil {
sendStatus(sshSessionStreamMessage{Type: "status", Status: "connecting", Message: "connecting"}) sendStatus(sshSessionStreamMessage{Type: "status", Status: "connecting", Message: "connecting"})
} }
sshConfig = &ssh.ClientConfig{ reportDialCancelledBySession = func() bool {
User: profile.RemoteUsername, closeReason = runtimeSession.CloseReason()
Auth: authMethods, closeCode = runtimeSession.CloseCode()
HostKeyCallback: sshHostKeyCallback(hostKeys, &connectedFingerprint, &connectedHostKey, prepared.PinHostKeyOnFirstUse), if closeReason == "" {
Timeout: 15 * time.Second, 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
} }
sshClient, err = ssh.Dial("tcp", net.JoinHostPort(profile.Server.Host, fmt.Sprintf("%d", profile.Server.Port)), sshConfig) 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),
}
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 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) api.logSSHSessionConnectFailure(sessionItem, &profile, user, "dial", err)
if sendStatus != nil { if sendStatus != nil {
sendStatus(sshSessionStreamMessage{Type: "status", Status: "error", Message: err.Error()}) sendStatus(sshSessionStreamMessage{Type: "status", Status: "error", Message: err.Error()})
@@ -3652,14 +3766,19 @@ func (api *API) readSSHSessionWebsocket(ws *websocket.Conn, inputCh chan<- sshSe
msg = sshSessionStreamMessage{} msg = sshSessionStreamMessage{}
err = websocket.JSON.Receive(ws, &msg) err = websocket.JSON.Receive(ws, &msg)
if err != nil { 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 errCh <- err
return return
} }
select { select {
case inputCh <- msg: case inputCh <- msg:
default: default:
errCh <- errors.New("websocket input queue full") errCh <- errors.New("websocket input queue full")
return return
} }
} }
} }
+1
View File
@@ -651,6 +651,7 @@ type SSHAccessProfileTarget struct {
type SSHSession struct { type SSHSession struct {
ID string `json:"id"` ID string `json:"id"`
ProfileID string `json:"profile_id"` ProfileID string `json:"profile_id"`
ProfileName string `json:"profile_name"`
ServerID string `json:"server_id"` ServerID string `json:"server_id"`
ServerName string `json:"server_name"` ServerName string `json:"server_name"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
+3
View File
@@ -1125,6 +1125,7 @@ export type SSHPrincipalGrantUpsertPayload = {
export interface SSHSession { export interface SSHSession {
id: string id: string
profile_id: string profile_id: string
profile_name: string
server_id: string server_id: string
server_name: string server_name: string
user_id: string user_id: string
@@ -1152,6 +1153,7 @@ export interface SSHSessionListResponse {
items: SSHSession[] items: SSHSession[]
limit: number limit: number
offset: number offset: number
total: number
has_more: boolean has_more: boolean
} }
@@ -1238,6 +1240,7 @@ export interface SSHFileTransferListResponse {
items: SSHFileTransfer[] items: SSHFileTransfer[]
limit: number limit: number
offset: number offset: number
total: number
has_more: boolean has_more: boolean
} }
+4 -1
View File
@@ -10,12 +10,15 @@ type PageAlertProps = {
sx?: SxProps<Theme> sx?: SxProps<Theme>
} }
const mySx: SxProps<Theme> = { mr: 1, ml: 0.5 }
export default function PageAlert(props: PageAlertProps) { 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 const content: ReactNode = props.children || props.message
if (!content) return null if (!content) return null
return ( return (
<Alert severity={props.severity || 'error'} onClose={props.onClose} sx={props.sx}> <Alert severity={props.severity || 'error'} onClose={props.onClose} sx={sx}>
{content} {content}
</Alert> </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 Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import MenuItem from '@mui/material/MenuItem'
import TextField from '@mui/material/TextField' import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import Autocomplete from './Autocomplete' import Autocomplete from './Autocomplete'
@@ -27,6 +26,7 @@ type SSHSessionCreateDialogProps = {
export default function SSHSessionCreateDialog(props: SSHSessionCreateDialogProps) { export default function SSHSessionCreateDialog(props: SSHSessionCreateDialogProps) {
const needsServerSelection: boolean = Boolean(props.selectedProfile?.server_target_type === 'group') 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 ( return (
<Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth> <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" />} renderInput={(params) => <TextField {...params} label="SSH Access Profile" />}
/> />
{needsServerSelection ? ( {needsServerSelection ? (
<TextField <Autocomplete<SSHServer, false, false, false>
select options={props.serverOptions}
label="SSH Server" value={selectedServer}
value={props.selectedServerID} onChange={(_, value) => props.onSelectedServerIDChange(value?.id || '')}
onChange={(event) => props.onSelectedServerIDChange(event.target.value)} getOptionLabel={(item) => `${item.name} (${item.host}:${item.port})`}
isOptionEqualToValue={(option, value) => option.id === value.id}
disabled={props.busy || props.serversLoading || props.serverOptions.length === 0} disabled={props.busy || props.serversLoading || props.serverOptions.length === 0}
helperText={props.serversLoading ? 'Loading servers...' : 'Select a target server from this server group.'} loading={props.serversLoading}
> renderInput={(params) => (
{props.serverOptions.map((item: SSHServer) => ( <TextField
<MenuItem key={item.id} value={item.id}> {...params}
{item.name} ({item.host}:{item.port}) label="SSH Server"
</MenuItem> helperText={props.serversLoading ? 'Loading servers...' : 'Select a target server from this server group.'}
))} />
</TextField> )}
/>
) : null} ) : null}
{needsServerSelection && !props.serversLoading && props.serverOptions.length === 0 ? ( {needsServerSelection && !props.serversLoading && props.serverOptions.length === 0 ? (
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
+171 -137
View File
@@ -14,8 +14,12 @@ import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import useMediaQuery from '@mui/material/useMediaQuery' import useMediaQuery from '@mui/material/useMediaQuery'
import { useTheme } from '@mui/material/styles' 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 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 Tooltip from '@mui/material/Tooltip'
import { MutableRefObject, PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { MutableRefObject, PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useBlocker, useLocation, useNavigate, useParams } from 'react-router-dom' import { useBlocker, useLocation, useNavigate, useParams } from 'react-router-dom'
@@ -30,6 +34,8 @@ import SSHSessionFileUploadDialog from '../components/SSHSessionFileUploadDialog
import SSHTranscriptDialog from '../components/SSHTranscriptDialog' import SSHTranscriptDialog from '../components/SSHTranscriptDialog'
import TintedPanel from '../components/TintedPanel' import TintedPanel from '../components/TintedPanel'
import PageAlert from '../components/PageAlert' 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' import { api, SSHAccessProfile, SSHFileTransfer, SSHFileTransferListResponse, SSHServer, SSHSession, SSHSessionConnectResponse, SSHSessionListResponse } from '../api'
type SSHSessionStreamMessage = { type SSHSessionStreamMessage = {
@@ -47,6 +53,11 @@ type SessionSummary = {
status: string status: string
} }
type TransferSessionFilterOption = {
id: string
label: string
}
type SessionStreamHandlers = { type SessionStreamHandlers = {
onOutput: (data: Uint8Array) => void onOutput: (data: Uint8Array) => void
onStatus: (message: SSHSessionStreamMessage) => void onStatus: (message: SSHSessionStreamMessage) => void
@@ -665,46 +676,52 @@ function SessionTerminalPanel(props: SessionPanelProps) {
}} }}
> >
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap', px: 0.25 }}> <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' }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, minWidth: 0 }}>
<Typography variant="subtitle2" > <Typography variant="subtitle1" noWrap >
{sessionTitle} {sessionTitle}
</Typography> </Typography>
{session ? ( {session ? (
<Typography variant="caption" color="text.secondary"> <Tooltip
Host key: {session.host_key_fingerprint || '-'} title={
</Typography> <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} ) : null}
</Box> </Box>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Button <Tooltip title={actionPending ? (canReconnect ? 'Connecting...' : 'Disconnecting...') : sessionConnectOrDisconnectLabel}>
variant="outlined" <span>
size="small" <IconButton size="small" onClick={() => void handleSessionConnectOrDisconnect()} disabled={!canSessionAction}>
onClick={() => void handleDuplicate()} {canReconnect ? <PlayArrowIcon fontSize="small" /> : <StopIcon fontSize="small" />}
disabled={!canDuplicate} </IconButton>
> </span>
Duplicate </Tooltip>
</Button> <Tooltip title="Duplicate">
<Button <span>
variant="outlined" <IconButton size="small" onClick={() => void handleDuplicate()} disabled={!canDuplicate}>
size="small" <ContentCopyIcon fontSize="small" />
onClick={() => void handleSessionConnectOrDisconnect()} </IconButton>
disabled={!canSessionAction} </span>
> </Tooltip>
{actionPending ? (canReconnect ? 'Connecting...' : 'Disconnecting...') : sessionConnectOrDisconnectLabel}
</Button>
{openSessionIDs.length > 1 ? ( {openSessionIDs.length > 1 ? (
<Tooltip title={isSynced ? 'Leave sync group' : 'Join sync group — share keystrokes with other synced terminals'}> <Tooltip title={isSynced ? 'Leave sync group' : 'Join sync group — share keystrokes with other synced terminals'}>
<span> <span>
<IconButton size="small" onClick={() => onToggleSync(sessionID)} color={isSynced ? 'warning' : 'default'} disabled={!isSynced && status !== 'connected'}> <IconButton size="small" onClick={() => onToggleSync(sessionID)} color={isSynced ? 'warning' : 'default'} disabled={!isSynced && status !== 'connected'}>
<KeyboardIcon fontSize="small" /> <KeyboardIcon fontSize="small" />
</IconButton> </IconButton>
</span> </span>
</Tooltip> </Tooltip>
) : null} ) : null}
<IconButton size="small" onClick={handleOpenActionMenu}> <Tooltip title="More options">
<MoreVertIcon fontSize="small" /> <IconButton size="small" onClick={handleOpenActionMenu}>
</IconButton> <MoreVertIcon fontSize="small" />
</IconButton>
</Tooltip>
<Menu <Menu
anchorEl={actionMenuAnchor} anchorEl={actionMenuAnchor}
open={actionMenuOpen} open={actionMenuOpen}
@@ -817,7 +834,7 @@ export default function SSHSessionPage() {
const [historyStatus, setHistoryStatus] = useState('') const [historyStatus, setHistoryStatus] = useState('')
const [historyLimit, setHistoryLimit] = useState(10) const [historyLimit, setHistoryLimit] = useState(10)
const [historyOffset, setHistoryOffset] = useState(0) const [historyOffset, setHistoryOffset] = useState(0)
const [historyHasMore, setHistoryHasMore] = useState(false) const [historyTotal, setHistoryTotal] = useState(0)
const [historyOpen, setHistoryOpen] = useState(false) const [historyOpen, setHistoryOpen] = useState(false)
const [transferItems, setTransferItems] = useState<SSHFileTransfer[]>([]) const [transferItems, setTransferItems] = useState<SSHFileTransfer[]>([])
const [loadingTransfers, setLoadingTransfers] = useState(false) const [loadingTransfers, setLoadingTransfers] = useState(false)
@@ -827,7 +844,7 @@ export default function SSHSessionPage() {
const [transferSessionFilter, setTransferSessionFilter] = useState('') const [transferSessionFilter, setTransferSessionFilter] = useState('')
const [transferLimit, setTransferLimit] = useState(25) const [transferLimit, setTransferLimit] = useState(25)
const [transferOffset, setTransferOffset] = useState(0) const [transferOffset, setTransferOffset] = useState(0)
const [transferHasMore, setTransferHasMore] = useState(false) const [transferTotal, setTransferTotal] = useState(0)
const [transfersOpen, setTransfersOpen] = useState(false) const [transfersOpen, setTransfersOpen] = useState(false)
const [historyConnectItem, setHistoryConnectItem] = useState<SSHSession | null>(null) const [historyConnectItem, setHistoryConnectItem] = useState<SSHSession | null>(null)
const [historyConnectPassword, setHistoryConnectPassword] = useState('') const [historyConnectPassword, setHistoryConnectPassword] = useState('')
@@ -859,12 +876,41 @@ export default function SSHSessionPage() {
const workspaceRef = useRef<HTMLDivElement | null>(null) const workspaceRef = useRef<HTMLDivElement | null>(null)
const streamWSRef = useRef<WebSocket | null>(null) const streamWSRef = useRef<WebSocket | null>(null)
const streamMessageQueueRef = useRef<SSHSessionStreamMessage[]>([]) const streamMessageQueueRef = useRef<SSHSessionStreamMessage[]>([])
const historyPagePendingRef = useRef(false)
const transferPagePendingRef = useRef(false)
const [streamWSCheck, setStreamWSCheck] = useState<number>(0) const [streamWSCheck, setStreamWSCheck] = useState<number>(0)
const streamHandlersRef = useRef<Record<string, SessionStreamHandlers>>({}) const streamHandlersRef = useRef<Record<string, SessionStreamHandlers>>({})
const attachedSessionIDsRef = useRef<Record<string, boolean>>({}) const attachedSessionIDsRef = useRef<Record<string, boolean>>({})
const stackedLayout = useMediaQuery(theme.breakpoints.down('lg')) const stackedLayout = useMediaQuery(theme.breakpoints.down('lg'))
const historyDialogMode: boolean = useMediaQuery(theme.breakpoints.down('md')) 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(() => { const rowCount = useMemo(() => {
if (stackedLayout) { if (stackedLayout) {
return Math.max(1, openSessionIDs.length) return Math.max(1, openSessionIDs.length)
@@ -947,14 +993,17 @@ export default function SSHSessionPage() {
status: historyStatus || undefined status: historyStatus || undefined
}, signal ? { signal } : undefined) }, signal ? { signal } : undefined)
setHistoryItems(Array.isArray(response.items) ? response.items : []) setHistoryItems(Array.isArray(response.items) ? response.items : [])
setHistoryHasMore(Boolean(response.has_more)) setHistoryTotal(response.total || 0)
} catch (err) { } catch (err) {
if (signal?.aborted) { return } if (signal?.aborted) { return }
setHistoryError(err instanceof Error ? err.message : 'Failed to load SSH session history') setHistoryError(err instanceof Error ? err.message : 'Failed to load SSH session history')
setHistoryItems([]) setHistoryItems([])
setHistoryHasMore(false) setHistoryTotal(0)
} finally { } finally {
if (!signal?.aborted) setLoadingHistory(false) if (!signal?.aborted) {
historyPagePendingRef.current = false
setLoadingHistory(false)
}
} }
}, [historyLimit, historyOffset, historyQuery, historyStatus]) }, [historyLimit, historyOffset, historyQuery, historyStatus])
@@ -974,14 +1023,17 @@ export default function SSHSessionPage() {
session_id: transferSessionFilter || undefined session_id: transferSessionFilter || undefined
}, signal ? { signal } : undefined) }, signal ? { signal } : undefined)
setTransferItems(Array.isArray(response.items) ? response.items : []) setTransferItems(Array.isArray(response.items) ? response.items : [])
setTransferHasMore(Boolean(response.has_more)) setTransferTotal(response.total || 0)
} catch (err) { } catch (err) {
if (signal?.aborted) { return } if (signal?.aborted) { return }
setTransferError(err instanceof Error ? err.message : 'Failed to load SSH file transfer history') setTransferError(err instanceof Error ? err.message : 'Failed to load SSH file transfer history')
setTransferItems([]) setTransferItems([])
setTransferHasMore(false) setTransferTotal(0)
} finally { } finally {
if (!signal?.aborted) setLoadingTransfers(false) if (!signal?.aborted) {
transferPagePendingRef.current = false
setLoadingTransfers(false)
}
} }
}, [transferLimit, transferOffset, transferQuery, transferSessionFilter, transferStatus]) }, [transferLimit, transferOffset, transferQuery, transferSessionFilter, transferStatus])
@@ -1246,23 +1298,19 @@ export default function SSHSessionPage() {
downloadTextFile(`ssh-session-${transcriptSession.id}.txt`, transcriptText) downloadTextFile(`ssh-session-${transcriptSession.id}.txt`, transcriptText)
}, [transcriptSession, transcriptText]) }, [transcriptSession, transcriptText])
const handleHistoryPrev = useCallback(() => { const handleHistoryOffsetChange = useCallback((offset: number) => {
setHistoryOffset((prev) => Math.max(0, prev - historyLimit)) if (historyPagePendingRef.current || loadingHistory) { return }
}, [historyLimit]) historyPagePendingRef.current = true
setLoadingHistory(true)
setHistoryOffset(Math.max(0, offset))
}, [loadingHistory])
const handleHistoryNext = useCallback(() => { const handleTransferOffsetChange = useCallback((offset: number) => {
if (!historyHasMore) { return } if (transferPagePendingRef.current || loadingTransfers) { return }
setHistoryOffset((prev) => prev + historyLimit) transferPagePendingRef.current = true
}, [historyHasMore, historyLimit]) setLoadingTransfers(true)
setTransferOffset(Math.max(0, offset))
const handleTransferPrev = useCallback(() => { }, [loadingTransfers])
setTransferOffset((prev: number) => Math.max(0, prev - transferLimit))
}, [transferLimit])
const handleTransferNext = useCallback(() => {
if (!transferHasMore) { return }
setTransferOffset((prev: number) => prev + transferLimit)
}, [transferHasMore, transferLimit])
const queueWorkspaceMessage = useCallback((message: SSHSessionStreamMessage) => { const queueWorkspaceMessage = useCallback((message: SSHSessionStreamMessage) => {
const queueLimit: number = 500 const queueLimit: number = 500
@@ -1647,9 +1695,14 @@ export default function SSHSessionPage() {
}, [dividerThickness, minPanelHeight, rowCount, rowDragState]) }, [dividerThickness, minPanelHeight, rowCount, rowDragState])
const historyContent = ( 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: 'grid', gap: 0.5, flex: '0 0 auto' }}>
<Typography variant="h6">Session History</Typography> <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"> <Typography variant="body2" color="text.secondary">
Recent SSH sessions for your account. Recent SSH sessions for your account.
</Typography> </Typography>
@@ -1660,6 +1713,8 @@ export default function SSHSessionPage() {
label="Search" label="Search"
value={historyQuery} value={historyQuery}
onChange={(event) => { onChange={(event) => {
historyPagePendingRef.current = true
setLoadingHistory(true)
setHistoryQuery(event.target.value) setHistoryQuery(event.target.value)
setHistoryOffset(0) setHistoryOffset(0)
}} }}
@@ -1671,6 +1726,8 @@ export default function SSHSessionPage() {
label="Status" label="Status"
value={historyStatus} value={historyStatus}
onChange={(event) => { onChange={(event) => {
historyPagePendingRef.current = true
setLoadingHistory(true)
setHistoryStatus(event.target.value) setHistoryStatus(event.target.value)
setHistoryOffset(0) setHistoryOffset(0)
}} }}
@@ -1683,27 +1740,9 @@ export default function SSHSessionPage() {
<MenuItem value="closed">closed</MenuItem> <MenuItem value="closed">closed</MenuItem>
<MenuItem value="error">error</MenuItem> <MenuItem value="error">error</MenuItem>
</TextField> </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> </Box>
<PageAlert message={historyError} onClose={() => setHistoryError(null)} /> <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 ? ( {loadingHistory && !historyItems.length ? (
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
Loading... Loading...
@@ -1717,6 +1756,7 @@ export default function SSHSessionPage() {
{historyItems.map((item) => { {historyItems.map((item) => {
const liveSummary: SessionSummary | undefined = sessionSummaries[item.id] const liveSummary: SessionSummary | undefined = sessionSummaries[item.id]
const liveStatus: string = liveSummary?.status || item.status || 'unknown' const liveStatus: string = liveSummary?.status || item.status || 'unknown'
const profileText: string = item.profile_name || item.profile_id || ''
const primaryActionLabel: string = const primaryActionLabel: string =
liveStatus === 'pending' || liveStatus === 'connecting' || liveStatus === 'connected' liveStatus === 'pending' || liveStatus === 'connecting' || liveStatus === 'connected'
? 'Open' ? 'Open'
@@ -1741,8 +1781,13 @@ export default function SSHSessionPage() {
Started: {formatTimestamp(item.started_at)} · Ended: {formatTimestamp(item.ended_at)} Started: {formatTimestamp(item.started_at)} · Ended: {formatTimestamp(item.ended_at)}
</Typography> </Typography>
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}> <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> </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 ? ( {item.error ? (
<Typography variant="caption" color="error" sx={{ wordBreak: 'break-word' }}> <Typography variant="caption" color="error" sx={{ wordBreak: 'break-word' }}>
{item.error} {item.error}
@@ -1777,26 +1822,31 @@ export default function SSHSessionPage() {
) )
})} })}
</Box> </Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap', flex: '0 0 auto' }}> <PaginationControls
<Typography variant="caption" color="text.secondary"> offset={historyOffset}
Showing {historyItems.length ? historyOffset + 1 : 0}-{historyOffset + historyItems.length} limit={historyLimit}
</Typography> total={historyTotal}
<Box sx={{ display: 'flex', gap: 1 }}> loading={loadingHistory}
<Button variant="outlined" size="small" onClick={handleHistoryPrev} disabled={loadingHistory || historyOffset <= 0}> onOffsetChange={handleHistoryOffsetChange}
Previous onLimitChange={(value: number) => {
</Button> historyPagePendingRef.current = true
<Button variant="outlined" size="small" onClick={handleHistoryNext} disabled={loadingHistory || !historyHasMore}> setLoadingHistory(true)
Next setHistoryLimit(value)
</Button> setHistoryOffset(0)
</Box> }}
</Box> />
</Box> </Box>
) )
const transfersContent = ( 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: 'grid', gap: 0.5, flex: '0 0 auto' }}>
<Typography variant="h6">Transfer History</Typography> <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"> <Typography variant="body2" color="text.secondary">
Recent upload, download, and copy activity for your account. Recent upload, download, and copy activity for your account.
</Typography> </Typography>
@@ -1807,6 +1857,8 @@ export default function SSHSessionPage() {
label="Search" label="Search"
value={transferQuery} value={transferQuery}
onChange={(event) => { onChange={(event) => {
transferPagePendingRef.current = true
setLoadingTransfers(true)
setTransferQuery(event.target.value) setTransferQuery(event.target.value)
setTransferOffset(0) setTransferOffset(0)
}} }}
@@ -1818,6 +1870,8 @@ export default function SSHSessionPage() {
label="Status" label="Status"
value={transferStatus} value={transferStatus}
onChange={(event) => { onChange={(event) => {
transferPagePendingRef.current = true
setLoadingTransfers(true)
setTransferStatus(event.target.value) setTransferStatus(event.target.value)
setTransferOffset(0) setTransferOffset(0)
}} }}
@@ -1829,45 +1883,25 @@ export default function SSHSessionPage() {
<MenuItem value="cancelled">cancelled</MenuItem> <MenuItem value="cancelled">cancelled</MenuItem>
<MenuItem value="running">running</MenuItem> <MenuItem value="running">running</MenuItem>
</TextField> </TextField>
<TextField <Autocomplete<TransferSessionFilterOption, false, false, false>
select
size="small" size="small"
label="Session" options={transferSessionOptions}
value={transferSessionFilter} value={selectedTransferSessionOption}
onChange={(event) => { onChange={(_, value) => {
setTransferSessionFilter(event.target.value) transferPagePendingRef.current = true
setLoadingTransfers(true)
setTransferSessionFilter(value?.id || '')
setTransferOffset(0) setTransferOffset(0)
}} }}
sx={{ minWidth: 170 }} openOnFocus
> getOptionLabel={(item) => item.label}
<MenuItem value="">All sessions</MenuItem> isOptionEqualToValue={(option, value) => option.id === value.id}
{openSessionIDs.map((id: string) => ( sx={{ minWidth: 170, flex: '1 1 170px' }}
<MenuItem key={id} value={id}> renderInput={(params) => <TextField {...params} label="Session" placeholder="All sessions" />}
{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>
</Box> </Box>
<PageAlert message={transferError} onClose={() => setTransferError(null)} /> <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 ? ( {loadingTransfers && !transferItems.length ? (
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
Loading... Loading...
@@ -1927,19 +1961,19 @@ export default function SSHSessionPage() {
) )
})} })}
</Box> </Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap', flex: '0 0 auto' }}> <PaginationControls
<Typography variant="caption" color="text.secondary"> offset={transferOffset}
Showing {transferItems.length ? transferOffset + 1 : 0}-{transferOffset + transferItems.length} limit={transferLimit}
</Typography> total={transferTotal}
<Box sx={{ display: 'flex', gap: 1 }}> loading={loadingTransfers}
<Button variant="outlined" size="small" onClick={handleTransferPrev} disabled={loadingTransfers || transferOffset <= 0}> onOffsetChange={handleTransferOffsetChange}
Previous onLimitChange={(value: number) => {
</Button> transferPagePendingRef.current = true
<Button variant="outlined" size="small" onClick={handleTransferNext} disabled={loadingTransfers || !transferHasMore}> setLoadingTransfers(true)
Next setTransferLimit(value)
</Button> setTransferOffset(0)
</Box> }}
</Box> />
</Box> </Box>
) )