feat: implement Remote WinBox worker, API, frontend integration, OpenBao persistence, and supporting docs
This commit is contained in:
295
frontend/src/components/fleet/RemoteWinBoxButton.tsx
Normal file
295
frontend/src/components/fleet/RemoteWinBoxButton.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Globe, X, Loader2, RefreshCw, Maximize2, Minimize2 } from 'lucide-react'
|
||||
import { remoteWinboxApi, type RemoteWinBoxSession } from '@/lib/api'
|
||||
|
||||
interface RemoteWinBoxButtonProps {
|
||||
tenantId: string
|
||||
deviceId: string
|
||||
}
|
||||
|
||||
type State = 'idle' | 'requesting' | 'connecting' | 'active' | 'closing' | 'terminated' | 'failed'
|
||||
|
||||
export function RemoteWinBoxButton({ tenantId, deviceId }: RemoteWinBoxButtonProps) {
|
||||
const [state, setState] = useState<State>('idle')
|
||||
const [session, setSession] = useState<RemoteWinBoxSession | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [countdown, setCountdown] = useState<string | null>(null)
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Check for existing active sessions on mount
|
||||
const { data: existingSessions } = useQuery({
|
||||
queryKey: ['remote-winbox-sessions', tenantId, deviceId],
|
||||
queryFn: () => remoteWinboxApi.list(tenantId, deviceId),
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (existingSessions && state === 'idle') {
|
||||
const active = existingSessions.find(
|
||||
(s) => s.status === 'active' || s.status === 'creating',
|
||||
)
|
||||
if (active) {
|
||||
setSession(active)
|
||||
setState(active.status === 'active' ? 'active' : 'connecting')
|
||||
}
|
||||
}
|
||||
}, [existingSessions, state])
|
||||
|
||||
// Poll session status while connecting
|
||||
useEffect(() => {
|
||||
if (state !== 'connecting' || !session) return
|
||||
|
||||
const poll = setInterval(async () => {
|
||||
try {
|
||||
const updated = await remoteWinboxApi.get(tenantId, deviceId, session.session_id)
|
||||
setSession(updated)
|
||||
if (updated.status === 'active') {
|
||||
setState('active')
|
||||
} else if (updated.status === 'failed') {
|
||||
setState('failed')
|
||||
setError('Session failed to provision')
|
||||
} else if (updated.status === 'terminated') {
|
||||
setState('terminated')
|
||||
}
|
||||
} catch {
|
||||
// ignore transient polling errors
|
||||
}
|
||||
}, 2000)
|
||||
|
||||
pollRef.current = poll
|
||||
return () => clearInterval(poll)
|
||||
}, [state, session, tenantId, deviceId])
|
||||
|
||||
// Countdown timer for session expiry
|
||||
useEffect(() => {
|
||||
if (state !== 'active' || !session?.expires_at) {
|
||||
setCountdown(null)
|
||||
return
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
const remaining = Math.max(0, new Date(session.expires_at).getTime() - Date.now())
|
||||
if (remaining <= 0) {
|
||||
setCountdown('Expired')
|
||||
setState('terminated')
|
||||
return
|
||||
}
|
||||
const mins = Math.floor(remaining / 60000)
|
||||
const secs = Math.floor((remaining % 60000) / 1000)
|
||||
setCountdown(`${mins}:${secs.toString().padStart(2, '0')}`)
|
||||
}
|
||||
tick()
|
||||
const interval = setInterval(tick, 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [state, session?.expires_at])
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => remoteWinboxApi.create(tenantId, deviceId),
|
||||
onSuccess: (data) => {
|
||||
setSession(data)
|
||||
if (data.status === 'active') {
|
||||
setState('active')
|
||||
} else {
|
||||
setState('connecting')
|
||||
}
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setState('failed')
|
||||
setError(err.response?.data?.detail || 'Failed to create session')
|
||||
},
|
||||
})
|
||||
|
||||
const closeMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!session) throw new Error('No session')
|
||||
return remoteWinboxApi.delete(tenantId, deviceId, session.session_id)
|
||||
},
|
||||
onSuccess: () => {
|
||||
setState('idle')
|
||||
setSession(null)
|
||||
setError(null)
|
||||
queryClient.invalidateQueries({ queryKey: ['remote-winbox-sessions', tenantId, deviceId] })
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setState('failed')
|
||||
setError(err.response?.data?.detail || 'Failed to close session')
|
||||
},
|
||||
})
|
||||
|
||||
const handleOpen = useCallback(() => {
|
||||
setState('requesting')
|
||||
setError(null)
|
||||
createMutation.mutate()
|
||||
}, [createMutation])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setState('closing')
|
||||
closeMutation.mutate()
|
||||
}, [closeMutation])
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
setSession(null)
|
||||
setError(null)
|
||||
handleOpen()
|
||||
}, [handleOpen])
|
||||
|
||||
const handleReset = useCallback(async () => {
|
||||
try {
|
||||
const sessions = await remoteWinboxApi.list(tenantId, deviceId)
|
||||
for (const s of sessions) {
|
||||
if (s.status === 'active' || s.status === 'creating' || s.status === 'grace') {
|
||||
await remoteWinboxApi.delete(tenantId, deviceId, s.session_id)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
setState('idle')
|
||||
setSession(null)
|
||||
setError(null)
|
||||
queryClient.invalidateQueries({ queryKey: ['remote-winbox-sessions', tenantId, deviceId] })
|
||||
}, [tenantId, deviceId, queryClient])
|
||||
|
||||
// Build iframe URL: load Xpra HTML5 client directly via nginx /xpra/{port}/ proxy
|
||||
// path= tells the Xpra HTML5 client where to open the WebSocket connection
|
||||
const iframeSrc = session?.session_id && session?.xpra_ws_port
|
||||
? `/xpra/${session.xpra_ws_port}/index.html?path=/xpra/${session.xpra_ws_port}/&keyboard=false&floating_menu=false&sharing=false&clipboard=false`
|
||||
: null
|
||||
|
||||
// Idle / Failed / Terminated states — show button
|
||||
if (state === 'idle' || state === 'failed' || state === 'terminated') {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleOpen}
|
||||
disabled={createMutation.isPending}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Globe className="h-4 w-4" />
|
||||
)}
|
||||
{createMutation.isPending ? 'Starting...' : 'Remote WinBox'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground"
|
||||
title="Reset all remote WinBox sessions for this device"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
{state === 'failed' && error && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
{state === 'terminated' && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">Session ended</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Requesting / Connecting — spinner
|
||||
if (state === 'requesting' || state === 'connecting') {
|
||||
return (
|
||||
<div className="rounded-md border p-4 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<p className="text-sm font-medium">
|
||||
{state === 'requesting' ? 'Requesting session...' : 'Provisioning WinBox container...'}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">This may take a few seconds</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Closing
|
||||
if (state === 'closing') {
|
||||
return (
|
||||
<div className="rounded-md border p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<p className="text-sm font-medium">Closing session...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Active — show iframe
|
||||
if (state === 'active' && iframeSrc) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
expanded
|
||||
? 'fixed inset-0 z-50 bg-background flex flex-col'
|
||||
: 'rounded-md border flex flex-col'
|
||||
}
|
||||
>
|
||||
{/* Header bar */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4 text-primary" />
|
||||
<span className="text-sm font-medium">Remote WinBox</span>
|
||||
{countdown && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Expires in {countdown}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="p-1.5 rounded hover:bg-accent"
|
||||
title={expanded ? 'Minimize' : 'Maximize'}
|
||||
>
|
||||
{expanded ? (
|
||||
<Minimize2 className="h-4 w-4" />
|
||||
) : (
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
disabled={closeMutation.isPending}
|
||||
className="p-1.5 rounded hover:bg-accent disabled:opacity-50"
|
||||
title="Close session"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Xpra iframe */}
|
||||
<iframe
|
||||
src={iframeSrc}
|
||||
className={expanded ? 'flex-1 w-full' : 'w-full h-[600px]'}
|
||||
style={{ border: 'none' }}
|
||||
allow="clipboard-read; clipboard-write"
|
||||
title="Remote WinBox Session"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Active but no iframe URL (missing xpra_ws_port) — show reset option
|
||||
return (
|
||||
<div className="rounded-md border p-4 space-y-2">
|
||||
<p className="text-sm text-destructive">Session active but display unavailable</p>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-md border border-input bg-background hover:bg-accent text-sm"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,9 +3,9 @@ import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useAuth, isSuperAdmin, isTenantAdmin } from '@/lib/auth'
|
||||
import { authApi } from '@/lib/api'
|
||||
import { getSMTPSettings, updateSMTPSettings, testSMTPSettings } from '@/lib/settingsApi'
|
||||
import { getSMTPSettings, updateSMTPSettings, testSMTPSettings, clearWinboxSessions } from '@/lib/settingsApi'
|
||||
import { SMTP_PRESETS } from '@/lib/smtpPresets'
|
||||
import { Settings, User, Shield, Info, Key, Lock, ChevronRight, Download, Trash2, AlertTriangle, Mail } from 'lucide-react'
|
||||
import { Settings, User, Shield, Info, Key, Lock, ChevronRight, Download, Trash2, AlertTriangle, Mail, Monitor } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -149,6 +149,34 @@ export function SettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Maintenance — super_admin only */}
|
||||
{isSuperAdmin(user) && (
|
||||
<div className="rounded-lg border border-border bg-surface px-4 py-3 space-y-1">
|
||||
<SectionHeader icon={Monitor} title="Maintenance" />
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div>
|
||||
<span className="text-sm text-text-primary">Clear WinBox Sessions</span>
|
||||
<p className="text-xs text-text-muted">Remove stale sessions and rate limits from Redis</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const result = await clearWinboxSessions()
|
||||
toast.success(`Cleared ${result.deleted} key${result.deleted !== 1 ? 's' : ''} from Redis`)
|
||||
} catch {
|
||||
toast.error('Failed to clear WinBox sessions')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* System Email (SMTP) — super_admin only */}
|
||||
{isSuperAdmin(user) && <SMTPSettingsSection />}
|
||||
|
||||
|
||||
@@ -968,6 +968,59 @@ export const remoteAccessApi = {
|
||||
.then((r) => r.data),
|
||||
}
|
||||
|
||||
// ─── Remote WinBox (Browser) ─────────────────────────────────────────────────
|
||||
|
||||
export interface RemoteWinBoxSession {
|
||||
session_id: string
|
||||
status: 'creating' | 'active' | 'grace' | 'terminating' | 'terminated' | 'failed'
|
||||
websocket_path?: string
|
||||
xpra_ws_port?: number
|
||||
idle_timeout_seconds: number
|
||||
max_lifetime_seconds: number
|
||||
expires_at: string
|
||||
max_expires_at: string
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export const remoteWinboxApi = {
|
||||
create: (tenantId: string, deviceId: string, opts?: {
|
||||
idle_timeout_seconds?: number
|
||||
max_lifetime_seconds?: number
|
||||
}) =>
|
||||
api
|
||||
.post<RemoteWinBoxSession>(
|
||||
`/api/tenants/${tenantId}/devices/${deviceId}/winbox-remote-sessions`,
|
||||
opts || {},
|
||||
)
|
||||
.then((r) => r.data),
|
||||
|
||||
get: (tenantId: string, deviceId: string, sessionId: string) =>
|
||||
api
|
||||
.get<RemoteWinBoxSession>(
|
||||
`/api/tenants/${tenantId}/devices/${deviceId}/winbox-remote-sessions/${sessionId}`,
|
||||
)
|
||||
.then((r) => r.data),
|
||||
|
||||
list: (tenantId: string, deviceId: string) =>
|
||||
api
|
||||
.get<RemoteWinBoxSession[]>(
|
||||
`/api/tenants/${tenantId}/devices/${deviceId}/winbox-remote-sessions`,
|
||||
)
|
||||
.then((r) => r.data),
|
||||
|
||||
delete: (tenantId: string, deviceId: string, sessionId: string) =>
|
||||
api
|
||||
.delete(
|
||||
`/api/tenants/${tenantId}/devices/${deviceId}/winbox-remote-sessions/${sessionId}`,
|
||||
)
|
||||
.then((r) => r.data),
|
||||
|
||||
getWebSocketUrl: (sessionPath: string) => {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return `${proto}//${window.location.host}${sessionPath}`
|
||||
},
|
||||
}
|
||||
|
||||
// ─── Config History ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface ConfigChangeEntry {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* - localStorage and sessionStorage are NEVER used for any key material.
|
||||
*/
|
||||
|
||||
const DB_NAME = 'mikrotik-portal-keys';
|
||||
const DB_NAME = 'the-other-dude-keys';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = 'secret-keys';
|
||||
|
||||
|
||||
@@ -28,6 +28,11 @@ export async function updateSMTPSettings(data: {
|
||||
await api.put('/api/settings/smtp', data)
|
||||
}
|
||||
|
||||
export async function clearWinboxSessions(): Promise<{ status: string; deleted: number }> {
|
||||
const res = await api.delete('/api/settings/winbox-sessions')
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function testSMTPSettings(data: {
|
||||
to: string
|
||||
smtp_host?: string
|
||||
|
||||
@@ -57,6 +57,7 @@ import { useSimpleConfigMode } from '@/hooks/useSimpleConfig'
|
||||
import { SimpleModeToggle } from '@/components/simple-config/SimpleModeToggle'
|
||||
import { SimpleConfigView } from '@/components/simple-config/SimpleConfigView'
|
||||
import { WinBoxButton } from '@/components/fleet/WinBoxButton'
|
||||
import { RemoteWinBoxButton } from '@/components/fleet/RemoteWinBoxButton'
|
||||
import { SSHTerminal } from '@/components/fleet/SSHTerminal'
|
||||
|
||||
export const Route = createFileRoute(
|
||||
@@ -456,7 +457,10 @@ function DeviceDetailPage() {
|
||||
{user?.role !== 'viewer' && (
|
||||
<div className="flex gap-2">
|
||||
{device.routeros_version !== null && (
|
||||
<WinBoxButton tenantId={tenantId} deviceId={deviceId} />
|
||||
<>
|
||||
<WinBoxButton tenantId={tenantId} deviceId={deviceId} />
|
||||
<RemoteWinBoxButton tenantId={tenantId} deviceId={deviceId} />
|
||||
</>
|
||||
)}
|
||||
<SSHTerminal tenantId={tenantId} deviceId={deviceId} deviceName={device.hostname} />
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ setup('authenticate', async ({ page }) => {
|
||||
|
||||
// Use legacy-auth test user (no SRP/Secret Key required)
|
||||
await page.getByLabel(/email/i).fill(
|
||||
process.env.TEST_ADMIN_EMAIL || 'e2e-test@mikrotik-portal.dev'
|
||||
process.env.TEST_ADMIN_EMAIL || 'e2e-test@the-other-dude.dev'
|
||||
)
|
||||
await page.getByLabel(/password/i).fill(
|
||||
process.env.TEST_ADMIN_PASSWORD || 'admin123'
|
||||
|
||||
@@ -28,7 +28,7 @@ test.describe('Login Flow', () => {
|
||||
const loginPage = new LoginPage(page)
|
||||
await loginPage.goto()
|
||||
await loginPage.login(
|
||||
process.env.TEST_ADMIN_EMAIL || 'e2e-test@mikrotik-portal.dev',
|
||||
process.env.TEST_ADMIN_EMAIL || 'e2e-test@the-other-dude.dev',
|
||||
process.env.TEST_ADMIN_PASSWORD || 'admin123'
|
||||
)
|
||||
// Legacy auth user may trigger SRP upgrade dialog -- handle it
|
||||
|
||||
Reference in New Issue
Block a user