Viewer: - Toolbar: Ctrl+Alt+Del, clipboard paste, monitor picker, file transfer, chat, WoL buttons - Multi-monitor: agent sends monitor_list on connect, viewer can switch via dropdown - Clipboard sync: agent polls local clipboard → sends to viewer; viewer paste → agent sets remote clipboard - File transfer panel: drag-drop upload to agent, directory browser, download files from remote - Chat panel: bidirectional text chat forwarded through relay Agent: - Multi-monitor capture with set_monitor/set_quality message handlers - exec_key_combo for Ctrl+Alt+Del and arbitrary combos - Clipboard polling via pyperclip (both directions) - File upload/download/list_files with base64 chunked protocol - Attended mode (--attended): zenity/kdialog/PowerShell consent dialog before accepting stream - Auto-update: heartbeat checks version, downloads new binary and exec-replaces self (Linux) - Reports MAC address on registration (for WoL) Relay: - Forwards monitor_list, clipboard_content, file_chunk, file_list, chat_message agent→viewer - Session recording: when RECORDING_DIR env set, saves JPEG frames as .remrec files - ALLOWED_ORIGINS CORS now set from NEXT_PUBLIC_APP_URL in docker-compose Database: - groups table (id, name, description, created_by) - machines: group_id, mac_address, notes, tags text[] - Migration 0003 applied Dashboard: - Machines page: search, tag filter, group filter, inline notes/tags/rename editing - MachineCard: inline tag management, group picker, notes textarea - Admin page: new Groups tab (create/list/delete groups) - API: PATCH /api/machines/[id] (name, notes, tags, groupId) - API: GET/POST/DELETE /api/groups - API: POST /api/machines/wol (broadcast magic packet) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
294 lines
9.3 KiB
TypeScript
294 lines
9.3 KiB
TypeScript
'use client'
|
||
|
||
import { Button } from '@/components/ui/button'
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuLabel,
|
||
DropdownMenuSeparator,
|
||
DropdownMenuTrigger,
|
||
} from '@/components/ui/dropdown-menu'
|
||
import {
|
||
Maximize2,
|
||
Minimize2,
|
||
Monitor,
|
||
Signal,
|
||
SignalLow,
|
||
SignalMedium,
|
||
Keyboard,
|
||
MousePointer2,
|
||
Loader2,
|
||
CheckCircle2,
|
||
RefreshCw,
|
||
X,
|
||
Clipboard,
|
||
FolderOpen,
|
||
ChevronDown,
|
||
MessageSquare,
|
||
Zap,
|
||
} from 'lucide-react'
|
||
|
||
interface MonitorInfo {
|
||
index: number
|
||
width: number
|
||
height: number
|
||
primary: boolean
|
||
}
|
||
|
||
interface Session {
|
||
id: string
|
||
machineId: string | null
|
||
machineName: string | null
|
||
startedAt: string
|
||
}
|
||
|
||
type ConnectionState = 'connecting' | 'waiting' | 'connected' | 'disconnected' | 'error'
|
||
|
||
interface ViewerToolbarProps {
|
||
session: Session | null
|
||
connectionState: ConnectionState
|
||
isFullscreen: boolean
|
||
quality: 'high' | 'medium' | 'low'
|
||
isMuted: boolean
|
||
monitors: MonitorInfo[]
|
||
activeMonitor: number
|
||
onToggleFullscreen: () => void
|
||
onQualityChange: (quality: 'high' | 'medium' | 'low') => void
|
||
onToggleMute: () => void
|
||
onDisconnect: () => void
|
||
onReconnect: () => void
|
||
onCtrlAltDel: () => void
|
||
onClipboardPaste: () => void
|
||
onMonitorChange: (index: number) => void
|
||
onToggleFileTransfer: () => void
|
||
onToggleChat: () => void
|
||
onWakeOnLan?: () => void
|
||
machineName?: string
|
||
}
|
||
|
||
export function ViewerToolbar({
|
||
session,
|
||
connectionState,
|
||
isFullscreen,
|
||
quality,
|
||
monitors,
|
||
activeMonitor,
|
||
onToggleFullscreen,
|
||
onQualityChange,
|
||
onDisconnect,
|
||
onReconnect,
|
||
onCtrlAltDel,
|
||
onClipboardPaste,
|
||
onMonitorChange,
|
||
onToggleFileTransfer,
|
||
onToggleChat,
|
||
onWakeOnLan,
|
||
}: ViewerToolbarProps) {
|
||
const connected = connectionState === 'connected'
|
||
|
||
const getQualityIcon = () => {
|
||
switch (quality) {
|
||
case 'high': return Signal
|
||
case 'medium': return SignalMedium
|
||
case 'low': return SignalLow
|
||
}
|
||
}
|
||
|
||
const getConnectionBadge = () => {
|
||
switch (connectionState) {
|
||
case 'connecting':
|
||
return (
|
||
<span className="flex items-center gap-1.5 text-yellow-500 text-xs">
|
||
<Loader2 className="h-3 w-3 animate-spin" />
|
||
Connecting
|
||
</span>
|
||
)
|
||
case 'waiting':
|
||
return (
|
||
<span className="flex items-center gap-1.5 text-yellow-500 text-xs">
|
||
<span className="h-2 w-2 rounded-full bg-yellow-500 animate-pulse" />
|
||
Waiting for agent
|
||
</span>
|
||
)
|
||
case 'connected':
|
||
return (
|
||
<span className="flex items-center gap-1.5 text-green-500 text-xs">
|
||
<span className="h-2 w-2 rounded-full bg-green-500" />
|
||
Connected
|
||
</span>
|
||
)
|
||
case 'disconnected':
|
||
return (
|
||
<span className="flex items-center gap-1.5 text-muted-foreground text-xs">
|
||
<span className="h-2 w-2 rounded-full bg-muted-foreground" />
|
||
Disconnected
|
||
</span>
|
||
)
|
||
case 'error':
|
||
return (
|
||
<span className="flex items-center gap-1.5 text-destructive text-xs">
|
||
<span className="h-2 w-2 rounded-full bg-destructive" />
|
||
Error
|
||
</span>
|
||
)
|
||
}
|
||
}
|
||
|
||
const QualityIcon = getQualityIcon()
|
||
|
||
return (
|
||
<div className="flex items-center justify-between h-12 px-4 bg-card/95 backdrop-blur border-b border-border/50 gap-2">
|
||
{/* Left: machine name + status */}
|
||
<div className="flex items-center gap-4 min-w-0">
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<Monitor className="h-4 w-4 text-primary shrink-0" />
|
||
<span className="font-medium text-sm truncate">
|
||
{session?.machineName || 'Remote Machine'}
|
||
</span>
|
||
</div>
|
||
<div className="shrink-0">{getConnectionBadge()}</div>
|
||
</div>
|
||
|
||
{/* Right: controls */}
|
||
<div className="flex items-center gap-1 shrink-0">
|
||
{/* Input indicators */}
|
||
<div className="flex items-center gap-1.5 px-2 border-r border-border/50 mr-1">
|
||
<MousePointer2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||
<Keyboard className="h-3.5 w-3.5 text-muted-foreground" />
|
||
</div>
|
||
|
||
{/* Monitor picker (only shown when multiple monitors) */}
|
||
{connected && monitors.length > 1 && (
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button variant="ghost" size="sm" className="h-8 gap-1 px-2 text-xs">
|
||
<Monitor className="h-3.5 w-3.5" />
|
||
{monitors[activeMonitor]
|
||
? `${monitors[activeMonitor].width}×${monitors[activeMonitor].height}`
|
||
: 'Monitor'}
|
||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end">
|
||
<DropdownMenuLabel>Select Monitor</DropdownMenuLabel>
|
||
<DropdownMenuSeparator />
|
||
{monitors.map((m) => (
|
||
<DropdownMenuItem key={m.index} onClick={() => onMonitorChange(m.index)}>
|
||
<Monitor className="mr-2 h-4 w-4" />
|
||
{m.primary ? 'Primary' : `Display ${m.index + 1}`}
|
||
<span className="ml-auto text-xs text-muted-foreground">{m.width}×{m.height}</span>
|
||
{activeMonitor === m.index && <CheckCircle2 className="ml-2 h-4 w-4 text-primary" />}
|
||
</DropdownMenuItem>
|
||
))}
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
)}
|
||
|
||
{/* Ctrl+Alt+Del */}
|
||
{connected && (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-8 px-2 text-xs font-mono"
|
||
onClick={onCtrlAltDel}
|
||
title="Send Ctrl+Alt+Del"
|
||
>
|
||
CAD
|
||
</Button>
|
||
)}
|
||
|
||
{/* Clipboard paste */}
|
||
{connected && (
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-8 w-8"
|
||
onClick={onClipboardPaste}
|
||
title="Paste clipboard to remote"
|
||
>
|
||
<Clipboard className="h-4 w-4" />
|
||
<span className="sr-only">Paste clipboard</span>
|
||
</Button>
|
||
)}
|
||
|
||
{/* File transfer */}
|
||
{connected && (
|
||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onToggleFileTransfer} title="File transfer">
|
||
<FolderOpen className="h-4 w-4" />
|
||
<span className="sr-only">File transfer</span>
|
||
</Button>
|
||
)}
|
||
|
||
{/* Chat */}
|
||
{connected && (
|
||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onToggleChat} title="Session chat">
|
||
<MessageSquare className="h-4 w-4" />
|
||
<span className="sr-only">Chat</span>
|
||
</Button>
|
||
)}
|
||
|
||
{/* Wake-on-LAN (shown when agent is offline) */}
|
||
{!connected && onWakeOnLan && (
|
||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onWakeOnLan} title="Wake on LAN">
|
||
<Zap className="h-4 w-4 text-yellow-500" />
|
||
<span className="sr-only">Wake on LAN</span>
|
||
</Button>
|
||
)}
|
||
|
||
{/* Quality selector */}
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button variant="ghost" size="icon" className="h-8 w-8" title="Stream quality">
|
||
<QualityIcon className="h-4 w-4" />
|
||
<span className="sr-only">Quality</span>
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end">
|
||
<DropdownMenuLabel>Stream Quality</DropdownMenuLabel>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem onClick={() => onQualityChange('high')}>
|
||
<Signal className="mr-2 h-4 w-4" />
|
||
High
|
||
{quality === 'high' && <CheckCircle2 className="ml-auto h-4 w-4 text-primary" />}
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem onClick={() => onQualityChange('medium')}>
|
||
<SignalMedium className="mr-2 h-4 w-4" />
|
||
Medium
|
||
{quality === 'medium' && <CheckCircle2 className="ml-auto h-4 w-4 text-primary" />}
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem onClick={() => onQualityChange('low')}>
|
||
<SignalLow className="mr-2 h-4 w-4" />
|
||
Low
|
||
{quality === 'low' && <CheckCircle2 className="ml-auto h-4 w-4 text-primary" />}
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
|
||
{/* Fullscreen toggle */}
|
||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onToggleFullscreen} title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}>
|
||
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||
</Button>
|
||
|
||
{/* Reconnect */}
|
||
{(connectionState === 'disconnected' || connectionState === 'waiting') && (
|
||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onReconnect} title="Reconnect">
|
||
<RefreshCw className="h-4 w-4" />
|
||
</Button>
|
||
)}
|
||
|
||
{/* Disconnect */}
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||
onClick={onDisconnect}
|
||
title="End session"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|