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 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() }
|
||||||
|
|||||||
@@ -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,6 +2695,7 @@ 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 {
|
||||||
@@ -2662,15 +2706,15 @@ event_loop:
|
|||||||
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"})
|
||||||
}
|
}
|
||||||
|
reportDialCancelledBySession = func() bool {
|
||||||
|
closeReason = runtimeSession.CloseReason()
|
||||||
|
closeCode = runtimeSession.CloseCode()
|
||||||
|
if closeReason == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
now = time.Now().UTC().Unix()
|
||||||
|
_ = store.UpdateSSHSessionStatus(sessionItem.ID, "closed", "", 0, now, closeReason)
|
||||||
|
api.logSSHSessionClosed(sessionItem, &profile, user, "closed", closeReason, 0, now)
|
||||||
|
if sendStatus != nil && !isSSHSessionTransportCloseCode(closeCode) {
|
||||||
|
sendStatus(sshSessionStreamMessage{Type: "status", Status: "closed", Message: closeReason})
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
dialAddr = net.JoinHostPort(profile.Server.Host, fmt.Sprintf("%d", profile.Server.Port))
|
||||||
sshConfig = &ssh.ClientConfig{
|
sshConfig = &ssh.ClientConfig{
|
||||||
User: profile.RemoteUsername,
|
User: profile.RemoteUsername,
|
||||||
Auth: authMethods,
|
Auth: authMethods,
|
||||||
HostKeyCallback: sshHostKeyCallback(hostKeys, &connectedFingerprint, &connectedHostKey, prepared.PinHostKeyOnFirstUse),
|
HostKeyCallback: sshHostKeyCallback(hostKeys, &connectedFingerprint, &connectedHostKey, prepared.PinHostKeyOnFirstUse),
|
||||||
Timeout: 15 * time.Second,
|
|
||||||
}
|
}
|
||||||
sshClient, err = ssh.Dial("tcp", net.JoinHostPort(profile.Server.Host, fmt.Sprintf("%d", profile.Server.Port)), sshConfig)
|
dialCtx, dialCancel = context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
runtimeSession.SetDialCancel(dialCancel)
|
||||||
|
defer dialCancel()
|
||||||
|
dialConn, err = (&net.Dialer{}).DialContext(dialCtx, "tcp", dialAddr)
|
||||||
if err != nil {
|
if 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,9 +3766,14 @@ 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:
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
loading={props.serversLoading}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="SSH Server"
|
||||||
helperText={props.serversLoading ? 'Loading servers...' : 'Select a target server from this server group.'}
|
helperText={props.serversLoading ? 'Loading servers...' : 'Select a target server from this server group.'}
|
||||||
>
|
/>
|
||||||
{props.serverOptions.map((item: SSHServer) => (
|
)}
|
||||||
<MenuItem key={item.id} value={item.id}>
|
/>
|
||||||
{item.name} ({item.host}:{item.port})
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
) : null}
|
) : 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">
|
||||||
|
|||||||
@@ -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,34 +676,38 @@ function SessionTerminalPanel(props: SessionPanelProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap', px: 0.25 }}>
|
<Box sx={{ 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>
|
||||||
@@ -702,9 +717,11 @@ function SessionTerminalPanel(props: SessionPanelProps) {
|
|||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
|
<Tooltip title="More options">
|
||||||
<IconButton size="small" onClick={handleOpenActionMenu}>
|
<IconButton size="small" onClick={handleOpenActionMenu}>
|
||||||
<MoreVertIcon fontSize="small" />
|
<MoreVertIcon fontSize="small" />
|
||||||
</IconButton>
|
</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' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
||||||
<Typography variant="h6">Session History</Typography>
|
<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' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
||||||
<Typography variant="h6">Transfer History</Typography>
|
<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>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user