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 Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog' import Dialog from '@mui/material/Dialog'
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 Autocomplete from './Autocomplete' import Autocomplete from './Autocomplete'
import FormDialogContent from './FormDialogContent' import FormDialogContent from './FormDialogContent'
import { SSHAccessProfile } from '../api' import { SSHAccessProfile, SSHServer } from '../api'
type SSHSessionCreateDialogProps = { type SSHSessionCreateDialogProps = {
open: boolean open: boolean
profiles: SSHAccessProfile[] profiles: SSHAccessProfile[]
selectedProfile: SSHAccessProfile | null selectedProfile: SSHAccessProfile | null
selectedServerID: string
serverOptions: SSHServer[]
serversLoading: boolean
error: string | null
onSelectedProfileChange: (value: SSHAccessProfile | null) => void onSelectedProfileChange: (value: SSHAccessProfile | null) => void
onSelectedServerIDChange: (value: string) => void
busy: boolean busy: boolean
onClose: () => void onClose: () => void
onConnect: () => void onConnect: () => void
} }
export default function SSHSessionCreateDialog(props: SSHSessionCreateDialogProps) { export default function SSHSessionCreateDialog(props: SSHSessionCreateDialogProps) {
const needsServerSelection: boolean = Boolean(props.selectedProfile?.server_target_type === 'group')
return ( return (
<Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth> <Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth>
<DialogTitle>New SSH Session</DialogTitle> <DialogTitle>New SSH Session</DialogTitle>
<FormDialogContent> <FormDialogContent>
{props.error ? <Alert severity="error">{props.error}</Alert> : null}
<Autocomplete<SSHAccessProfile, false, false, false> <Autocomplete<SSHAccessProfile, false, false, false>
options={props.profiles} options={props.profiles}
value={props.selectedProfile} value={props.selectedProfile}
onChange={(_, value) => props.onSelectedProfileChange(value)} 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" />} 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> </FormDialogContent>
<DialogActions> <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'} {props.busy ? 'Connecting...' : 'Connect'}
</Button> </Button>
<Button onClick={props.onClose} disabled={props.busy}> <Button onClick={props.onClose} disabled={props.busy}>
+5 -4
View File
@@ -324,15 +324,16 @@ export default function SSHServersPage() {
setConnectSelectedServerIDs([]) setConnectSelectedServerIDs([])
} }
const openConnectedSessions = (sessionIDs: string[]) => { const openConnectedSessions = (sessionIDs: string[], failures: string[] = []) => {
let encodedIDs: string let encodedIDs: string
const state: { connectFailures?: string[] } | undefined = failures.length > 0 ? { connectFailures: failures } : undefined
if (sessionIDs.length === 1) { if (sessionIDs.length === 1) {
navigate(`/ssh-sessions/${sessionIDs[0]}`) navigate(`/ssh-sessions/${sessionIDs[0]}`, state ? { state } : undefined)
return return
} }
encodedIDs = encodeURIComponent(sessionIDs.join(',')) encodedIDs = encodeURIComponent(sessionIDs.join(','))
navigate(`/ssh-sessions?sessions=${encodedIDs}`) navigate(`/ssh-sessions?sessions=${encodedIDs}`, state ? { state } : undefined)
} }
const getConnectServerLabel = (item: SSHAccessProfile, serverID: string): string => { const getConnectServerLabel = (item: SSHAccessProfile, serverID: string): string => {
@@ -373,7 +374,7 @@ export default function SSHServersPage() {
} }
clearConnectServerSelection() clearConnectServerSelection()
closeConnectPasswordPrompt() closeConnectPasswordPrompt()
openConnectedSessions(sessions) openConnectedSessions(sessions, failures)
if (failures.length > 0) { if (failures.length > 0) {
setError(`${failures.length} connection${failures.length === 1 ? '' : 's'} failed.`) setError(`${failures.length} connection${failures.length === 1 ? '' : 's'} failed.`)
} }
+93 -7
View File
@@ -24,7 +24,7 @@ import SSHCredentialsPromptDialog from '../components/SSHCredentialsPromptDialog
import SSHSessionCreateDialog from '../components/SSHSessionCreateDialog' import SSHSessionCreateDialog from '../components/SSHSessionCreateDialog'
import SSHTranscriptDialog from '../components/SSHTranscriptDialog' import SSHTranscriptDialog from '../components/SSHTranscriptDialog'
import TintedPanel from '../components/TintedPanel' import TintedPanel from '../components/TintedPanel'
import { api, SSHAccessProfile, SSHSession, SSHSessionConnectResponse, SSHSessionListResponse } from '../api' import { api, SSHAccessProfile, SSHServer, SSHSession, SSHSessionConnectResponse, SSHSessionListResponse } from '../api'
type SSHSessionStreamMessage = { type SSHSessionStreamMessage = {
session_id?: string session_id?: string
@@ -668,12 +668,17 @@ export default function SSHSessionPage() {
const [transcriptError, setTranscriptError] = useState<string | null>(null) const [transcriptError, setTranscriptError] = useState<string | null>(null)
const [loadingTranscript, setLoadingTranscript] = useState(false) const [loadingTranscript, setLoadingTranscript] = useState(false)
const [pageError, setPageError] = useState<string | null>(null) const [pageError, setPageError] = useState<string | null>(null)
const [connectFailures, setConnectFailures] = useState<string[]>([])
const [newSessionOpen, setNewSessionOpen] = useState(false) const [newSessionOpen, setNewSessionOpen] = useState(false)
const [newSessionPasswordOpen, setNewSessionPasswordOpen] = useState(false) const [newSessionPasswordOpen, setNewSessionPasswordOpen] = useState(false)
const [newSessionPassword, setNewSessionPassword] = useState('') const [newSessionPassword, setNewSessionPassword] = useState('')
const [newSessionOTPCode, setNewSessionOTPCode] = useState('') const [newSessionOTPCode, setNewSessionOTPCode] = useState('')
const [newSessionPasswordError, setNewSessionPasswordError] = useState<string | null>(null) const [newSessionPasswordError, setNewSessionPasswordError] = useState<string | null>(null)
const [selectedProfile, setSelectedProfile] = useState<SSHAccessProfile | 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 [creatingSession, setCreatingSession] = useState(false)
const [splitRatio, setSplitRatio] = useState(0.5) const [splitRatio, setSplitRatio] = useState(0.5)
const [splitDragging, setSplitDragging] = useState(false) const [splitDragging, setSplitDragging] = useState(false)
@@ -721,11 +726,14 @@ export default function SSHSessionPage() {
}, [location.search]) }, [location.search])
useEffect(() => { useEffect(() => {
const state = location.state as { historyOpen?: boolean } | null const state = location.state as { historyOpen?: boolean, connectFailures?: string[] } | null
if (state?.historyOpen) { if (state?.historyOpen) {
setHistoryOpen(true) setHistoryOpen(true)
} }
if (Array.isArray(state?.connectFailures)) {
setConnectFailures(state.connectFailures)
}
}, [location.state]) }, [location.state])
const loadHistory = useCallback(async (signal?: AbortSignal) => { const loadHistory = useCallback(async (signal?: AbortSignal) => {
@@ -1113,6 +1121,35 @@ export default function SSHSessionPage() {
if (selectedProfile) setNewSessionOpen(true) 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) => { const handleCreateSession = async (password?: string, otpCode?: string) => {
let ti: TerminalInfo let ti: TerminalInfo
let nextSession: SSHSessionConnectResponse let nextSession: SSHSessionConnectResponse
@@ -1122,6 +1159,10 @@ export default function SSHSessionPage() {
setPageError('SSH access profile is required') setPageError('SSH access profile is required')
return return
} }
if (selectedProfile.server_target_type === 'group' && !selectedNewSessionServerID) {
setNewSessionError('Select an SSH server for this server group profile.')
return
}
if ((requiresPromptedPassword(selectedProfile) && password === undefined) || if ((requiresPromptedPassword(selectedProfile) && password === undefined) ||
(requiresPromptedTOTP(selectedProfile) && otpCode === undefined)) { (requiresPromptedTOTP(selectedProfile) && otpCode === undefined)) {
@@ -1146,6 +1187,9 @@ export default function SSHSessionPage() {
setNewSessionPasswordError(null) setNewSessionPasswordError(null)
try { try {
ti = buildInitialTerminalInfo(password, otpCode) ti = buildInitialTerminalInfo(password, otpCode)
if (selectedProfile.server_target_type === 'group') {
ti.server_id = selectedNewSessionServerID
}
nextSession = await api.connectSSHAccessProfileForSelf(selectedProfile.id, ti) nextSession = await api.connectSSHAccessProfileForSelf(selectedProfile.id, ti)
setOpenSessionIDs((prev) => (prev.includes(nextSession.session_id) ? prev : [...prev, nextSession.session_id])) setOpenSessionIDs((prev) => (prev.includes(nextSession.session_id) ? prev : [...prev, nextSession.session_id]))
setNewSessionOpen(false) setNewSessionOpen(false)
@@ -1153,6 +1197,9 @@ export default function SSHSessionPage() {
setNewSessionPassword('') setNewSessionPassword('')
setNewSessionOTPCode('') setNewSessionOTPCode('')
setSelectedProfile(null) setSelectedProfile(null)
setSelectedNewSessionServerID('')
setNewSessionServerOptions([])
setNewSessionError(null)
} catch (err) { } catch (err) {
message = err instanceof Error ? err.message : 'Failed to create SSH session' message = err instanceof Error ? err.message : 'Failed to create SSH session'
if (password !== undefined || otpCode !== undefined) { 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 openNewSessionDialog = async () => {
const ac: AbortController = new AbortController() const ac: AbortController = new AbortController()
setSelectedProfile(null) setSelectedProfile(null)
setSelectedNewSessionServerID('')
setNewSessionServerOptions([])
setNewSessionError(null)
setPageError(null) setPageError(null)
setNewSessionPasswordOpen(false) setNewSessionPasswordOpen(false)
setNewSessionPassword('') setNewSessionPassword('')
@@ -1428,7 +1493,7 @@ export default function SSHSessionPage() {
) )
return ( 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' }}> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="h5">SSH Sessions</Typography> <Typography variant="h5">SSH Sessions</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
@@ -1443,7 +1508,21 @@ export default function SSHSessionPage() {
</Button> </Button>
</Box> </Box>
</Box> </Box>
<Box sx={{ display: 'grid', gap: 1, minHeight: 0 }}>
{pageError ? <Alert severity="error">{pageError}</Alert> : null} {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 ? ( {/*openSessionIDs.length ? (
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{openSessionIDs.map((id) => ( {openSessionIDs.map((id) => (
@@ -1455,6 +1534,7 @@ export default function SSHSessionPage() {
))} ))}
</Box> </Box>
) :*/ null} ) :*/ null}
<Box sx={{ minHeight: 0, minWidth: 0 }}>
{openSessionIDs.length ? ( {openSessionIDs.length ? (
stackedLayout ? ( stackedLayout ? (
<Box <Box
@@ -1464,7 +1544,7 @@ export default function SSHSessionPage() {
gridTemplateColumns: 'minmax(0, 1fr)', gridTemplateColumns: 'minmax(0, 1fr)',
gridTemplateRows: gridTemplateRows, gridTemplateRows: gridTemplateRows,
alignItems: 'stretch', alignItems: 'stretch',
height: 'calc(100vh - 120px)', height: '100%',
minHeight: minPanelHeight, minHeight: minPanelHeight,
minWidth: 0 minWidth: 0
}} }}
@@ -1527,7 +1607,7 @@ export default function SSHSessionPage() {
gridTemplateColumns: (openSessionIDs.length > 1 ? `${splitRatio}fr 12px ${1 - splitRatio}fr` : 'minmax(0, 1fr)'), gridTemplateColumns: (openSessionIDs.length > 1 ? `${splitRatio}fr 12px ${1 - splitRatio}fr` : 'minmax(0, 1fr)'),
gridTemplateRows: gridTemplateRows, gridTemplateRows: gridTemplateRows,
alignItems: 'stretch', 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, minHeight: minPanelHeight,
minWidth: 0, minWidth: 0,
}} }}
@@ -1622,6 +1702,7 @@ export default function SSHSessionPage() {
</Box> </Box>
</TintedPanel> </TintedPanel>
)} )}
</Box>
{historyDialogMode ? ( {historyDialogMode ? (
<Dialog open={historyOpen} onClose={handleCloseHistory} fullScreen> <Dialog open={historyOpen} onClose={handleCloseHistory} fullScreen>
@@ -1677,7 +1758,12 @@ export default function SSHSessionPage() {
open={newSessionOpen} open={newSessionOpen}
profiles={profiles} profiles={profiles}
selectedProfile={selectedProfile} selectedProfile={selectedProfile}
onSelectedProfileChange={setSelectedProfile} selectedServerID={selectedNewSessionServerID}
serverOptions={newSessionServerOptions}
serversLoading={newSessionServersLoading}
error={newSessionError}
onSelectedProfileChange={(profile) => void handleNewSessionProfileChange(profile)}
onSelectedServerIDChange={setSelectedNewSessionServerID}
busy={creatingSession} busy={creatingSession}
onClose={() => setNewSessionOpen(false)} onClose={() => setNewSessionOpen(false)}
onConnect={() => void handleCreateSession()} onConnect={() => void handleCreateSession()}
@@ -1685,7 +1771,7 @@ export default function SSHSessionPage() {
<SSHCredentialsPromptDialog <SSHCredentialsPromptDialog
open={newSessionPasswordOpen} 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} error={newSessionPasswordError}
passwordRequired={Boolean(selectedProfile && requiresPromptedPassword(selectedProfile))} passwordRequired={Boolean(selectedProfile && requiresPromptedPassword(selectedProfile))}
password={newSessionPassword} password={newSessionPassword}