Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e9befe184 | |||
| 8584bef011 |
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user