Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b54d2fd3bf | |||
| 0556f0f1cf | |||
| c91b152bef | |||
| a3352cdcba | |||
| f1a80d3398 | |||
| 1c68d6cce4 | |||
| 4142a08b85 | |||
| 0910a9571d |
@@ -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() }
|
||||
|
||||
@@ -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,25 +2695,26 @@ 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 {
|
||||
api.SSHSessionRegistry.RequestClose(attachedID, websocketCloseCode)
|
||||
}
|
||||
select {
|
||||
case attachment.InputErrCh <- err:
|
||||
default: // make it non-blocking
|
||||
case attachment.InputErrCh <- err:
|
||||
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"})
|
||||
}
|
||||
sshConfig = &ssh.ClientConfig{
|
||||
User: profile.RemoteUsername,
|
||||
Auth: authMethods,
|
||||
HostKeyCallback: sshHostKeyCallback(hostKeys, &connectedFingerprint, &connectedHostKey, prepared.PinHostKeyOnFirstUse),
|
||||
Timeout: 15 * time.Second,
|
||||
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
|
||||
}
|
||||
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 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,14 +3766,19 @@ 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:
|
||||
errCh <- errors.New("websocket input queue full")
|
||||
return
|
||||
case inputCh <- msg:
|
||||
default:
|
||||
errCh <- errors.New("websocket input queue full")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
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>
|
||||
loading={props.serversLoading}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="SSH Server"
|
||||
helperText={props.serversLoading ? 'Loading servers...' : 'Select a target server from this server group.'}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
{needsServerSelection && !props.serversLoading && props.serverOptions.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
|
||||
@@ -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,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={{ 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>
|
||||
<IconButton size="small" onClick={() => onToggleSync(sessionID)} color={isSynced ? 'warning' : 'default'} disabled={!isSynced && status !== 'connected'}>
|
||||
<KeyboardIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={() => onToggleSync(sessionID)} color={isSynced ? 'warning' : 'default'} disabled={!isSynced && status !== 'connected'}>
|
||||
<KeyboardIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<IconButton size="small" onClick={handleOpenActionMenu}>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<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' }}>
|
||||
<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">
|
||||
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' }}>
|
||||
<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">
|
||||
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>
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user