Compare commits

..

2 Commits

3 changed files with 134 additions and 15 deletions
@@ -1,37 +1,69 @@
import Alert from '@mui/material/Alert'
import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog'
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'
import FormDialogContent from './FormDialogContent'
import { SSHAccessProfile } from '../api'
import { SSHAccessProfile, SSHServer } from '../api'
type SSHSessionCreateDialogProps = {
open: boolean
profiles: SSHAccessProfile[]
selectedProfile: SSHAccessProfile | null
selectedServerID: string
serverOptions: SSHServer[]
serversLoading: boolean
error: string | null
onSelectedProfileChange: (value: SSHAccessProfile | null) => void
onSelectedServerIDChange: (value: string) => void
busy: boolean
onClose: () => void
onConnect: () => void
}
export default function SSHSessionCreateDialog(props: SSHSessionCreateDialogProps) {
const needsServerSelection: boolean = Boolean(props.selectedProfile?.server_target_type === 'group')
return (
<Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth>
<DialogTitle>New SSH Session</DialogTitle>
<FormDialogContent>
{props.error ? <Alert severity="error">{props.error}</Alert> : null}
<Autocomplete<SSHAccessProfile, false, false, false>
options={props.profiles}
value={props.selectedProfile}
onChange={(_, value) => props.onSelectedProfileChange(value)}
getOptionLabel={(item) => `${item.name} · ${item.server?.name || item.server_id} · ${item.remote_username}`}
getOptionLabel={(item) => `${item.name} · ${item.server_target_type === 'group' ? (item.server_group?.name || item.server_group_id) : (item.server?.name || item.server_id)} · ${item.remote_username}`}
renderInput={(params) => <TextField {...params} label="SSH Access Profile" />}
/>
{needsServerSelection ? (
<TextField
select
label="SSH Server"
value={props.selectedServerID}
onChange={(event) => props.onSelectedServerIDChange(event.target.value)}
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>
) : null}
{needsServerSelection && !props.serversLoading && props.serverOptions.length === 0 ? (
<Typography variant="body2" color="text.secondary">
No enabled servers are available in this group.
</Typography>
) : null}
</FormDialogContent>
<DialogActions>
<Button variant="contained" onClick={props.onConnect} disabled={props.busy || !props.selectedProfile}>
<Button variant="contained" onClick={props.onConnect} disabled={props.busy || !props.selectedProfile || (needsServerSelection && !props.selectedServerID)}>
{props.busy ? 'Connecting...' : 'Connect'}
</Button>
<Button onClick={props.onClose} disabled={props.busy}>
+5 -4
View File
@@ -324,15 +324,16 @@ export default function SSHServersPage() {
setConnectSelectedServerIDs([])
}
const openConnectedSessions = (sessionIDs: string[]) => {
const openConnectedSessions = (sessionIDs: string[], failures: string[] = []) => {
let encodedIDs: string
const state: { connectFailures?: string[] } | undefined = failures.length > 0 ? { connectFailures: failures } : undefined
if (sessionIDs.length === 1) {
navigate(`/ssh-sessions/${sessionIDs[0]}`)
navigate(`/ssh-sessions/${sessionIDs[0]}`, state ? { state } : undefined)
return
}
encodedIDs = encodeURIComponent(sessionIDs.join(','))
navigate(`/ssh-sessions?sessions=${encodedIDs}`)
navigate(`/ssh-sessions?sessions=${encodedIDs}`, state ? { state } : undefined)
}
const getConnectServerLabel = (item: SSHAccessProfile, serverID: string): string => {
@@ -373,7 +374,7 @@ export default function SSHServersPage() {
}
clearConnectServerSelection()
closeConnectPasswordPrompt()
openConnectedSessions(sessions)
openConnectedSessions(sessions, failures)
if (failures.length > 0) {
setError(`${failures.length} connection${failures.length === 1 ? '' : 's'} failed.`)
}
+94 -8
View File
@@ -24,7 +24,7 @@ import SSHCredentialsPromptDialog from '../components/SSHCredentialsPromptDialog
import SSHSessionCreateDialog from '../components/SSHSessionCreateDialog'
import SSHTranscriptDialog from '../components/SSHTranscriptDialog'
import TintedPanel from '../components/TintedPanel'
import { api, SSHAccessProfile, SSHSession, SSHSessionConnectResponse, SSHSessionListResponse } from '../api'
import { api, SSHAccessProfile, SSHServer, SSHSession, SSHSessionConnectResponse, SSHSessionListResponse } from '../api'
type SSHSessionStreamMessage = {
session_id?: string
@@ -668,12 +668,17 @@ export default function SSHSessionPage() {
const [transcriptError, setTranscriptError] = useState<string | null>(null)
const [loadingTranscript, setLoadingTranscript] = useState(false)
const [pageError, setPageError] = useState<string | null>(null)
const [connectFailures, setConnectFailures] = useState<string[]>([])
const [newSessionOpen, setNewSessionOpen] = useState(false)
const [newSessionPasswordOpen, setNewSessionPasswordOpen] = useState(false)
const [newSessionPassword, setNewSessionPassword] = useState('')
const [newSessionOTPCode, setNewSessionOTPCode] = useState('')
const [newSessionPasswordError, setNewSessionPasswordError] = useState<string | null>(null)
const [selectedProfile, setSelectedProfile] = useState<SSHAccessProfile | null>(null)
const [selectedNewSessionServerID, setSelectedNewSessionServerID] = useState('')
const [newSessionServerOptions, setNewSessionServerOptions] = useState<SSHServer[]>([])
const [newSessionServersLoading, setNewSessionServersLoading] = useState(false)
const [newSessionError, setNewSessionError] = useState<string | null>(null)
const [creatingSession, setCreatingSession] = useState(false)
const [splitRatio, setSplitRatio] = useState(0.5)
const [splitDragging, setSplitDragging] = useState(false)
@@ -721,11 +726,14 @@ export default function SSHSessionPage() {
}, [location.search])
useEffect(() => {
const state = location.state as { historyOpen?: boolean } | null
const state = location.state as { historyOpen?: boolean, connectFailures?: string[] } | null
if (state?.historyOpen) {
setHistoryOpen(true)
}
if (Array.isArray(state?.connectFailures)) {
setConnectFailures(state.connectFailures)
}
}, [location.state])
const loadHistory = useCallback(async (signal?: AbortSignal) => {
@@ -1113,6 +1121,35 @@ export default function SSHSessionPage() {
if (selectedProfile) setNewSessionOpen(true)
}
const handleNewSessionProfileChange = async (profile: SSHAccessProfile | null) => {
let servers: SSHServer[]
let enabledServers: SSHServer[]
setSelectedProfile(profile)
setSelectedNewSessionServerID('')
setNewSessionServerOptions([])
setNewSessionError(null)
if (!profile || profile.server_target_type !== 'group') {
return
}
setNewSessionServersLoading(true)
try {
servers = await api.listSSHAccessProfileServersForSelf(profile.id)
enabledServers = (Array.isArray(servers) ? servers : []).filter((item: SSHServer) => item.enabled)
setNewSessionServerOptions(enabledServers)
if (enabledServers.length === 1) {
setSelectedNewSessionServerID(enabledServers[0].id)
}
if (enabledServers.length === 0) {
setNewSessionError('No enabled servers are available in this group.')
}
} catch (err) {
setNewSessionError(err instanceof Error ? err.message : 'Failed to load SSH servers')
} finally {
setNewSessionServersLoading(false)
}
}
const handleCreateSession = async (password?: string, otpCode?: string) => {
let ti: TerminalInfo
let nextSession: SSHSessionConnectResponse
@@ -1122,6 +1159,10 @@ export default function SSHSessionPage() {
setPageError('SSH access profile is required')
return
}
if (selectedProfile.server_target_type === 'group' && !selectedNewSessionServerID) {
setNewSessionError('Select an SSH server for this server group profile.')
return
}
if ((requiresPromptedPassword(selectedProfile) && password === undefined) ||
(requiresPromptedTOTP(selectedProfile) && otpCode === undefined)) {
@@ -1146,6 +1187,9 @@ export default function SSHSessionPage() {
setNewSessionPasswordError(null)
try {
ti = buildInitialTerminalInfo(password, otpCode)
if (selectedProfile.server_target_type === 'group') {
ti.server_id = selectedNewSessionServerID
}
nextSession = await api.connectSSHAccessProfileForSelf(selectedProfile.id, ti)
setOpenSessionIDs((prev) => (prev.includes(nextSession.session_id) ? prev : [...prev, nextSession.session_id]))
setNewSessionOpen(false)
@@ -1153,6 +1197,9 @@ export default function SSHSessionPage() {
setNewSessionPassword('')
setNewSessionOTPCode('')
setSelectedProfile(null)
setSelectedNewSessionServerID('')
setNewSessionServerOptions([])
setNewSessionError(null)
} catch (err) {
message = err instanceof Error ? err.message : 'Failed to create SSH session'
if (password !== undefined || otpCode !== undefined) {
@@ -1165,10 +1212,28 @@ export default function SSHSessionPage() {
}
}
const getNewSessionPromptTargetText = (): string => {
let server: SSHServer | undefined
if (!selectedProfile) {
return 'Enter the password for this SSH access profile.'
}
if (selectedProfile.server_target_type === 'group') {
server = newSessionServerOptions.find((item: SSHServer) => item.id === selectedNewSessionServerID)
if (server) {
return `${selectedProfile.remote_username}@${server.host}:${server.port}`
}
}
return `${selectedProfile.remote_username}@${selectedProfile.server?.host || selectedProfile.server_id}:${selectedProfile.server?.port || 22}`
}
const openNewSessionDialog = async () => {
const ac: AbortController = new AbortController()
setSelectedProfile(null)
setSelectedNewSessionServerID('')
setNewSessionServerOptions([])
setNewSessionError(null)
setPageError(null)
setNewSessionPasswordOpen(false)
setNewSessionPassword('')
@@ -1428,7 +1493,7 @@ export default function SSHSessionPage() {
)
return (
<Box sx={{ display: 'grid', gap: 1 }}>
<Box sx={{ display: 'grid', gridTemplateRows: 'auto auto minmax(0, 1fr)', gap: 1, height: 'calc(100vh - 76px)', minHeight: 0, overflow: 'hidden' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="h5">SSH Sessions</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
@@ -1443,7 +1508,21 @@ export default function SSHSessionPage() {
</Button>
</Box>
</Box>
{pageError ? <Alert severity="error">{pageError}</Alert> : null}
<Box sx={{ display: 'grid', gap: 1, minHeight: 0 }}>
{pageError ? <Alert severity="error">{pageError}</Alert> : null}
{connectFailures.length > 0 ? (
<Alert severity="warning" onClose={() => setConnectFailures([])}>
<Typography variant="body2">
{connectFailures.length} SSH connection{connectFailures.length === 1 ? '' : 's'} failed.
</Typography>
{connectFailures.map((item: string) => (
<Typography key={item} variant="caption" component="div">
{item}
</Typography>
))}
</Alert>
) : null}
</Box>
{/*openSessionIDs.length ? (
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{openSessionIDs.map((id) => (
@@ -1455,6 +1534,7 @@ export default function SSHSessionPage() {
))}
</Box>
) :*/ null}
<Box sx={{ minHeight: 0, minWidth: 0 }}>
{openSessionIDs.length ? (
stackedLayout ? (
<Box
@@ -1464,7 +1544,7 @@ export default function SSHSessionPage() {
gridTemplateColumns: 'minmax(0, 1fr)',
gridTemplateRows: gridTemplateRows,
alignItems: 'stretch',
height: 'calc(100vh - 120px)',
height: '100%',
minHeight: minPanelHeight,
minWidth: 0
}}
@@ -1527,7 +1607,7 @@ export default function SSHSessionPage() {
gridTemplateColumns: (openSessionIDs.length > 1 ? `${splitRatio}fr 12px ${1 - splitRatio}fr` : 'minmax(0, 1fr)'),
gridTemplateRows: gridTemplateRows,
alignItems: 'stretch',
height: 'calc(100vh - 120px)', // TODO: how to make this fit the content? (must subtract the height of the topbar and the page's title area
height: '100%',
minHeight: minPanelHeight,
minWidth: 0,
}}
@@ -1622,6 +1702,7 @@ export default function SSHSessionPage() {
</Box>
</TintedPanel>
)}
</Box>
{historyDialogMode ? (
<Dialog open={historyOpen} onClose={handleCloseHistory} fullScreen>
@@ -1677,7 +1758,12 @@ export default function SSHSessionPage() {
open={newSessionOpen}
profiles={profiles}
selectedProfile={selectedProfile}
onSelectedProfileChange={setSelectedProfile}
selectedServerID={selectedNewSessionServerID}
serverOptions={newSessionServerOptions}
serversLoading={newSessionServersLoading}
error={newSessionError}
onSelectedProfileChange={(profile) => void handleNewSessionProfileChange(profile)}
onSelectedServerIDChange={setSelectedNewSessionServerID}
busy={creatingSession}
onClose={() => setNewSessionOpen(false)}
onConnect={() => void handleCreateSession()}
@@ -1685,7 +1771,7 @@ export default function SSHSessionPage() {
<SSHCredentialsPromptDialog
open={newSessionPasswordOpen}
targetText={selectedProfile ? `${selectedProfile.remote_username}@${selectedProfile.server?.host || selectedProfile.server_id}:${selectedProfile.server?.port || 22}` : 'Enter the password for this SSH access profile.'}
targetText={getNewSessionPromptTargetText()}
error={newSessionPasswordError}
passwordRequired={Boolean(selectedProfile && requiresPromptedPassword(selectedProfile))}
password={newSessionPassword}