Add ScreenConnect-parity features (high + medium)

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>
This commit is contained in:
monoadmin
2026-04-10 23:57:47 -07:00
parent 27673daa63
commit 61edbf59bf
20 changed files with 1881 additions and 241 deletions

View File

@@ -9,32 +9,41 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Maximize2,
Minimize2,
Monitor,
Volume2,
VolumeX,
Settings,
X,
RefreshCw,
import {
Maximize2,
Minimize2,
Monitor,
Signal,
SignalLow,
SignalMedium,
Keyboard,
MousePointer2,
Loader2,
CheckCircle2
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
machine_id: string | null
machine_name: string | null
started_at: string
machineId: string | null
machineName: string | null
startedAt: string
}
type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'error'
type ConnectionState = 'connecting' | 'waiting' | 'connected' | 'disconnected' | 'error'
interface ViewerToolbarProps {
session: Session | null
@@ -42,11 +51,20 @@ interface ViewerToolbarProps {
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({
@@ -54,13 +72,21 @@ export function ViewerToolbar({
connectionState,
isFullscreen,
quality,
isMuted,
monitors,
activeMonitor,
onToggleFullscreen,
onQualityChange,
onToggleMute,
onDisconnect,
onReconnect,
onCtrlAltDel,
onClipboardPaste,
onMonitorChange,
onToggleFileTransfer,
onToggleChat,
onWakeOnLan,
}: ViewerToolbarProps) {
const connected = connectionState === 'connected'
const getQualityIcon = () => {
switch (quality) {
case 'high': return Signal
@@ -73,28 +99,35 @@ export function ViewerToolbar({
switch (connectionState) {
case 'connecting':
return (
<span className="flex items-center gap-2 text-yellow-500">
<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-2 text-green-500">
<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-2 text-muted-foreground">
<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-2 text-destructive">
<span className="flex items-center gap-1.5 text-destructive text-xs">
<span className="h-2 w-2 rounded-full bg-destructive" />
Error
</span>
@@ -105,32 +138,108 @@ export function ViewerToolbar({
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">
{/* Left section */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Monitor className="h-4 w-4 text-primary" />
<span className="font-medium text-sm">
{session?.machine_name || 'Remote Machine'}
<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="text-sm">
{getConnectionBadge()}
</div>
<div className="shrink-0">{getConnectionBadge()}</div>
</div>
{/* Right section */}
<div className="flex items-center gap-1">
{/* Right: controls */}
<div className="flex items-center gap-1 shrink-0">
{/* Input indicators */}
<div className="flex items-center gap-2 px-3 border-r border-border/50 mr-2">
<MousePointer2 className="h-4 w-4 text-muted-foreground" />
<Keyboard className="h-4 w-4 text-muted-foreground" />
<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">
<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>
@@ -156,43 +265,27 @@ export function ViewerToolbar({
</DropdownMenuContent>
</DropdownMenu>
{/* Mute toggle */}
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onToggleMute}>
{isMuted ? (
<VolumeX className="h-4 w-4" />
) : (
<Volume2 className="h-4 w-4" />
)}
<span className="sr-only">{isMuted ? 'Unmute' : 'Mute'}</span>
</Button>
{/* Fullscreen toggle */}
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onToggleFullscreen}>
{isFullscreen ? (
<Minimize2 className="h-4 w-4" />
) : (
<Maximize2 className="h-4 w-4" />
)}
<span className="sr-only">{isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}</span>
<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' && (
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onReconnect}>
{(connectionState === 'disconnected' || connectionState === 'waiting') && (
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onReconnect} title="Reconnect">
<RefreshCw className="h-4 w-4" />
<span className="sr-only">Reconnect</span>
</Button>
)}
{/* Disconnect */}
<Button
variant="ghost"
size="icon"
<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" />
<span className="sr-only">Disconnect</span>
</Button>
</div>
</div>