Compare commits

...

2 Commits

6 changed files with 61 additions and 31 deletions
+7 -5
View File
@@ -1,14 +1,16 @@
import { CssBaseline, GlobalStyles } from '@mui/material' import { CssBaseline, GlobalStyles } from '@mui/material'
import { ThemeProvider } from '@mui/material/styles' import { ThemeProvider } from '@mui/material/styles'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useRoutes } from 'react-router-dom' import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import { routes } from './routes'
import { ThemeModeContext } from './ThemeModeContext' import { ThemeModeContext } from './ThemeModeContext'
import { createAppTheme, isThemeScheme, ThemeScheme } from './theme' import { createAppTheme, isThemeScheme, ThemeScheme } from './theme'
import { BrandContext, defaultAppBrand, storageKey } from '../branding' import { BrandContext, defaultAppBrand, storageKey } from '../branding'
export default function App() { type AppProps = {
const element = useRoutes(routes) router: ReturnType<typeof createBrowserRouter>
}
export default function App({ router }: AppProps) {
const [mode, setMode] = useState<'light' | 'dark'>(() => { const [mode, setMode] = useState<'light' | 'dark'>(() => {
const stored = localStorage.getItem(storageKey('theme-mode')) const stored = localStorage.getItem(storageKey('theme-mode'))
return stored === 'dark' ? 'dark' : 'light' return stored === 'dark' ? 'dark' : 'light'
@@ -82,7 +84,7 @@ export default function App() {
: {}) : {})
}} }}
/> />
{element} <RouterProvider router={router} />
</ThemeProvider> </ThemeProvider>
</ThemeModeContext.Provider> </ThemeModeContext.Provider>
</BrandContext.Provider> </BrandContext.Provider>
+7 -4
View File
@@ -1,12 +1,15 @@
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom' import { createBrowserRouter } from 'react-router-dom'
import App from './app/App' import App from './app/App'
import { routes } from './app/routes'
const router = createBrowserRouter(routes, {
future: { v7_startTransition: true, v7_relativeSplatPath: true }
})
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}> <App router={router} />
<App />
</BrowserRouter>
</React.StrictMode> </React.StrictMode>
) )
+2 -2
View File
@@ -491,7 +491,7 @@ export default function AdminSSHServersPage() {
<CompactListItemText <CompactListItemText
primary={ primary={
<Typography component="div"> <Typography component="div">
{item.name}({item.id}) {item.name} ({item.id})
{item.enabled? null: (<> <Chip size="small" color='error' label='Disabled'/></>)} {item.enabled? null: (<> <Chip size="small" color='error' label='Disabled'/></>)}
</Typography> </Typography>
} }
@@ -561,7 +561,7 @@ export default function AdminSSHServersPage() {
<CompactListItemText <CompactListItemText
primary={ primary={
<Typography component="div"> <Typography component="div">
{item.name}({item.id}) {item.name} ({item.id})
{item.enabled ? null : (<> <Chip size="small" color="error" label="Disabled" /></>)} {item.enabled ? null : (<> <Chip size="small" color="error" label="Disabled" /></>)}
</Typography> </Typography>
} }
+1 -1
View File
@@ -2,11 +2,11 @@ import Alert from '@mui/material/Alert'
import CompactListItemText from '../components/CompactListItemText' import CompactListItemText from '../components/CompactListItemText'
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog' import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
import FormDialogContent from '../components/FormDialogContent' import FormDialogContent from '../components/FormDialogContent'
import Dialog from '../components/ModalDialog'
import { import {
Box, Box,
Button, Button,
Chip, Chip,
Dialog,
DialogActions, DialogActions,
DialogTitle, DialogTitle,
List, List,
+1 -1
View File
@@ -756,7 +756,7 @@ export default function SSHServersPage() {
<CompactListItemText <CompactListItemText
primary={ primary={
<Typography component="div"> <Typography component="div">
{item.name}({item.id}) {item.name} ({item.id})
{item.enabled ? null : (<> <Chip size="small" color="error" label="Disabled" /></>)} {item.enabled ? null : (<> <Chip size="small" color="error" label="Disabled" /></>)}
</Typography> </Typography>
} }
+43 -18
View File
@@ -16,7 +16,7 @@ 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 MoreVertIcon from '@mui/icons-material/MoreVert'
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 { useLocation, useNavigate, useParams } from 'react-router-dom' import { useBlocker, useLocation, useNavigate, useParams } from 'react-router-dom'
import { FitAddon, ITerminalDimensions } from '@xterm/addon-fit' import { FitAddon, ITerminalDimensions } from '@xterm/addon-fit'
import { Unicode11Addon } from '@xterm/addon-unicode11' import { Unicode11Addon } from '@xterm/addon-unicode11'
import { Terminal } from '@xterm/xterm' import { Terminal } from '@xterm/xterm'
@@ -69,6 +69,7 @@ type SessionPanelProps = {
onClose: (sessionID: string) => void onClose: (sessionID: string) => void
onReplace: (sessionID: string, nextSessionID: string) => void onReplace: (sessionID: string, nextSessionID: string) => void
onSummaryChange: (sessionID: string, summary: SessionSummary) => void onSummaryChange: (sessionID: string, summary: SessionSummary) => void
onCheckCanAddPanel: () => boolean
} }
type TerminalInfo = { type TerminalInfo = {
@@ -177,7 +178,7 @@ function isSSHSessionStreamActive(status: string): boolean {
} }
function SessionTerminalPanel(props: SessionPanelProps) { function SessionTerminalPanel(props: SessionPanelProps) {
const { sessionID, openSessionIDs, layoutToken, registerStream, unregisterStream, sendStreamInput, sendStreamResize, onDuplicate, onClose, onReplace, onSummaryChange } = props const { sessionID, openSessionIDs, layoutToken, registerStream, unregisterStream, sendStreamInput, sendStreamResize, onDuplicate, onClose, onReplace, onSummaryChange, onCheckCanAddPanel } = props
const [session, setSession] = useState<SSHSession | null>(null) const [session, setSession] = useState<SSHSession | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -464,6 +465,10 @@ function SessionTerminalPanel(props: SessionPanelProps) {
let message: string let message: string
if (!session || !session.profile_id) return if (!session || !session.profile_id) return
if (mode === 'duplicate' && onCheckCanAddPanel()) {
setError('Cannot add a new terminal panel - not enough vertical space.')
return
}
setActionPending(true) setActionPending(true)
setError(null) setError(null)
@@ -792,7 +797,6 @@ 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 [leaveConfirmOpen, setLeaveConfirmOpen] = useState(false)
const [connectFailures, setConnectFailures] = useState<string[]>([]) 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)
@@ -824,6 +828,20 @@ export default function SSHSessionPage() {
return Math.max(1, Math.ceil(openSessionIDs.length / 2)) return Math.max(1, Math.ceil(openSessionIDs.length / 2))
}, [openSessionIDs.length, stackedLayout]) }, [openSessionIDs.length, stackedLayout])
const wouldExceedMinHeight = useCallback((): boolean => {
const currentCount = openSessionIDs.length
const newCount = currentCount + 1
const currentRows = stackedLayout ? Math.max(1, currentCount) : Math.max(1, Math.ceil(currentCount / 2))
const newRows = stackedLayout ? Math.max(1, newCount) : Math.max(1, Math.ceil(newCount / 2))
if (newRows <= currentRows) { return false }
const container = workspaceRef.current
if (!container) { return false }
const containerHeight = container.getBoundingClientRect().height
if (containerHeight <= 0) { return false }
const contentHeight = containerHeight - (newRows - 1) * dividerThickness
return (contentHeight / newRows) < minPanelHeight
}, [openSessionIDs.length, stackedLayout, dividerThickness, minPanelHeight])
useEffect(() => { useEffect(() => {
if (!sessionId) { return } if (!sessionId) { return }
setOpenSessionIDs((prev) => (prev.includes(sessionId) ? prev : [...prev, sessionId])) setOpenSessionIDs((prev) => (prev.includes(sessionId) ? prev : [...prev, sessionId]))
@@ -990,6 +1008,8 @@ export default function SSHSessionPage() {
return summaries.some((item: SessionSummary) => item.status === 'connected' || item.status === 'connecting') return summaries.some((item: SessionSummary) => item.status === 'connected' || item.status === 'connecting')
}, [sessionSummaries]) }, [sessionSummaries])
const blocker = useBlocker(hasConnectedOpenSession)
const gridTemplateRows = useMemo(() => { const gridTemplateRows = useMemo(() => {
if (rowCount <= 1) return `minmax(${minPanelHeight}px, 1fr)` if (rowCount <= 1) return `minmax(${minPanelHeight}px, 1fr)`
return rowWeights.flatMap((value, index) => { return rowWeights.flatMap((value, index) => {
@@ -1032,12 +1052,16 @@ export default function SSHSessionPage() {
}, []) }, [])
const handleOpenHistorySession = useCallback((id: string) => { const handleOpenHistorySession = useCallback((id: string) => {
if (!openSessionIDs.includes(id) && wouldExceedMinHeight()) {
setPageError('Cannot add a new terminal panel - not enough vertical space.')
return
}
setOpenSessionIDs((prev) => (prev.includes(id) ? prev : [...prev, id])) setOpenSessionIDs((prev) => (prev.includes(id) ? prev : [...prev, id]))
if (historyDialogMode) { if (historyDialogMode) {
setHistoryOpen(false) setHistoryOpen(false)
} }
navigate(`/ssh-sessions/${id}`) navigate(`/ssh-sessions/${id}`)
}, [historyDialogMode, navigate]) }, [historyDialogMode, navigate, openSessionIDs, wouldExceedMinHeight])
const handleOpenHistory = useCallback(() => { const handleOpenHistory = useCallback(() => {
setHistoryOpen(true) setHistoryOpen(true)
@@ -1070,6 +1094,10 @@ export default function SSHSessionPage() {
let nextSession: SSHSessionConnectResponse let nextSession: SSHSessionConnectResponse
let message: string let message: string
if (wouldExceedMinHeight()) {
setPageError('Cannot add a new terminal panel - not enough vertical space.')
return
}
setPageError(null) setPageError(null)
setHistoryConnectPasswordError(null) setHistoryConnectPasswordError(null)
setConnectingHistorySession(true) setConnectingHistorySession(true)
@@ -1095,7 +1123,7 @@ export default function SSHSessionPage() {
} finally { } finally {
setConnectingHistorySession(false) setConnectingHistorySession(false)
} }
}, [historyDialogMode]) }, [historyDialogMode, wouldExceedMinHeight])
const handleHistoryPrimaryAction = useCallback((item: SSHSession, liveStatus: string) => { const handleHistoryPrimaryAction = useCallback((item: SSHSession, liveStatus: string) => {
if (liveStatus === 'pending' || liveStatus === 'connecting' || liveStatus === 'connected') { if (liveStatus === 'pending' || liveStatus === 'connecting' || liveStatus === 'connected') {
@@ -1376,6 +1404,10 @@ export default function SSHSessionPage() {
} }
// proceed to create session // proceed to create session
if (wouldExceedMinHeight()) {
setNewSessionError('Cannot add a new terminal panel - not enough vertical space.')
return
}
setCreatingSession(true) setCreatingSession(true)
setPageError(null) setPageError(null)
setNewSessionPasswordError(null) setNewSessionPasswordError(null)
@@ -1440,15 +1472,6 @@ export default function SSHSessionPage() {
} }
const handleLeave = () => { const handleLeave = () => {
if (hasConnectedOpenSession) {
setLeaveConfirmOpen(true)
return
}
navigate('/ssh-servers')
}
const handleConfirmLeave = () => {
setLeaveConfirmOpen(false)
navigate('/ssh-servers') navigate('/ssh-servers')
} }
@@ -1952,6 +1975,7 @@ export default function SSHSessionPage() {
onClose={handleCloseSession} onClose={handleCloseSession}
onReplace={handleReplaceSession} onReplace={handleReplaceSession}
onSummaryChange={handleSummaryChange} onSummaryChange={handleSummaryChange}
onCheckCanAddPanel={wouldExceedMinHeight}
/> />
</Box> </Box>
))} ))}
@@ -2041,6 +2065,7 @@ export default function SSHSessionPage() {
onClose={handleCloseSession} onClose={handleCloseSession}
onReplace={handleReplaceSession} onReplace={handleReplaceSession}
onSummaryChange={handleSummaryChange} onSummaryChange={handleSummaryChange}
onCheckCanAddPanel={wouldExceedMinHeight}
/> />
</Box> </Box>
))} ))}
@@ -2164,16 +2189,16 @@ export default function SSHSessionPage() {
onConfirm={() => void handleCreateSession(newSessionPassword, newSessionOTPCode)} onConfirm={() => void handleCreateSession(newSessionPassword, newSessionOTPCode)}
/> />
<Dialog open={leaveConfirmOpen} onClose={() => setLeaveConfirmOpen(false)} maxWidth="xs" fullWidth> <Dialog open={blocker.state === 'blocked'} onClose={() => blocker.reset?.()} maxWidth="xs" fullWidth>
<DialogTitle>Close SSH Sessions?</DialogTitle> <DialogTitle>Close SSH Sessions?</DialogTitle>
<DialogContent> <DialogContent>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
There are connected SSH sessions. Closing this page disconnects the open terminals. There are connected SSH sessions. Leaving this page disconnects the open terminals.
</Typography> </Typography>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={handleConfirmLeave} variant="contained" color="warning">Close Page</Button> <Button onClick={() => blocker.proceed?.()} variant="contained" color="warning">Leave Page</Button>
<Button onClick={() => setLeaveConfirmOpen(false)}>Cancel</Button> <Button onClick={() => blocker.reset?.()}>Cancel</Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
</Box> </Box>