Files
remotelink-docker/components/dashboard/machine-card.tsx
monoadmin 61edbf59bf 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>
2026-04-10 23:57:47 -07:00

281 lines
9.4 KiB
TypeScript

'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Laptop,
Link2,
MoreHorizontal,
Trash2,
Edit3,
Check,
X,
Tag,
StickyNote,
} from 'lucide-react'
import { formatDistanceToNow } from 'date-fns'
import Link from 'next/link'
interface Group {
id: string
name: string
}
interface Machine {
id: string
name: string
hostname: string | null
os: string | null
osVersion: string | null
agentVersion: string | null
lastSeen: Date | null
isOnline: boolean
tags: string[] | null
notes: string | null
groupId: string | null
}
interface MachineCardProps {
machine: Machine
groups: Group[]
}
const TAG_COLORS: Record<string, string> = {
server: 'bg-blue-500/10 text-blue-500 border-blue-500/20',
windows: 'bg-cyan-500/10 text-cyan-500 border-cyan-500/20',
linux: 'bg-orange-500/10 text-orange-500 border-orange-500/20',
mac: 'bg-gray-500/10 text-gray-400 border-gray-500/20',
workstation: 'bg-purple-500/10 text-purple-500 border-purple-500/20',
laptop: 'bg-green-500/10 text-green-500 border-green-500/20',
}
function tagColor(tag: string) {
return TAG_COLORS[tag.toLowerCase()] || 'bg-muted text-muted-foreground border-border/50'
}
export function MachineCard({ machine, groups }: MachineCardProps) {
const router = useRouter()
const [editing, setEditing] = useState(false)
const [name, setName] = useState(machine.name)
const [notes, setNotes] = useState(machine.notes ?? '')
const [tagInput, setTagInput] = useState('')
const [tags, setTags] = useState<string[]>(machine.tags ?? [])
const [groupId, setGroupId] = useState(machine.groupId ?? '')
const [saving, setSaving] = useState(false)
const [showNotes, setShowNotes] = useState(false)
const save = async () => {
setSaving(true)
await fetch(`/api/machines/${machine.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, notes: notes || null, tags, groupId: groupId || null }),
})
setSaving(false)
setEditing(false)
router.refresh()
}
const cancel = () => {
setName(machine.name)
setNotes(machine.notes ?? '')
setTags(machine.tags ?? [])
setGroupId(machine.groupId ?? '')
setEditing(false)
}
const addTag = () => {
const t = tagInput.trim().toLowerCase().replace(/\s+/g, '-')
if (t && !tags.includes(t)) setTags([...tags, t])
setTagInput('')
}
const removeTag = (t: string) => setTags(tags.filter(x => x !== t))
const deleteMachine = async () => {
if (!confirm(`Delete "${machine.name}"? This cannot be undone.`)) return
await fetch(`/api/machines/${machine.id}`, { method: 'DELETE' })
router.refresh()
}
const currentGroup = groups.find(g => g.id === (groupId || machine.groupId))
return (
<Card className="border-border/50">
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-2">
<div className="flex items-center gap-3 min-w-0">
<div className={`flex h-10 w-10 items-center justify-center rounded-lg shrink-0 ${machine.isOnline ? 'bg-green-500/10 text-green-500' : 'bg-muted text-muted-foreground'}`}>
<Laptop className="h-5 w-5" />
</div>
<div className="min-w-0">
{editing ? (
<Input
value={name}
onChange={e => setName(e.target.value)}
className="h-7 text-sm font-semibold"
autoFocus
/>
) : (
<p className="text-sm font-semibold truncate">{machine.name}</p>
)}
<p className="text-xs text-muted-foreground truncate">{machine.hostname || 'Unknown host'}</p>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setEditing(true)}>
<Edit3 className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setShowNotes(v => !v)}>
<StickyNote className="mr-2 h-4 w-4" />
{showNotes ? 'Hide notes' : 'Show notes'}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={deleteMachine} className="text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</CardHeader>
<CardContent className="space-y-3">
{/* Tags */}
<div className="flex flex-wrap gap-1 min-h-5">
{tags.map(t => (
<Badge
key={t}
variant="outline"
className={`text-xs py-0 h-5 ${tagColor(t)}`}
>
{t}
{editing && (
<button onClick={() => removeTag(t)} className="ml-1 hover:text-destructive">
<X className="h-2.5 w-2.5" />
</button>
)}
</Badge>
))}
{editing && (
<div className="flex gap-1">
<Input
value={tagInput}
onChange={e => setTagInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addTag() } }}
placeholder="Add tag…"
className="h-5 text-xs py-0 px-1.5 w-20"
/>
<Button size="sm" variant="ghost" className="h-5 px-1" onClick={addTag}>
<Tag className="h-3 w-3" />
</Button>
</div>
)}
</div>
{/* Group */}
{editing ? (
<div>
<label className="text-xs text-muted-foreground">Group</label>
<select
value={groupId}
onChange={e => setGroupId(e.target.value)}
className="mt-0.5 w-full text-xs rounded border border-border bg-background px-2 py-1"
>
<option value="">No group</option>
{groups.map(g => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
</div>
) : currentGroup ? (
<p className="text-xs text-muted-foreground">Group: {currentGroup.name}</p>
) : null}
{/* Stats */}
<div className="grid grid-cols-2 gap-1 text-xs">
<div className="text-muted-foreground">Status</div>
<div className={`flex items-center gap-1.5 ${machine.isOnline ? 'text-green-500' : 'text-muted-foreground'}`}>
<span className={`h-1.5 w-1.5 rounded-full ${machine.isOnline ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
{machine.isOnline ? 'Online' : 'Offline'}
</div>
<div className="text-muted-foreground">OS</div>
<div className="truncate">{machine.os || 'Unknown'} {machine.osVersion || ''}</div>
<div className="text-muted-foreground">Last seen</div>
<div className="truncate">
{machine.lastSeen ? formatDistanceToNow(new Date(machine.lastSeen), { addSuffix: true }) : 'Never'}
</div>
<div className="text-muted-foreground">Agent</div>
<div className="font-mono">{machine.agentVersion || 'N/A'}</div>
</div>
{/* Notes */}
{(showNotes || editing) && (
<div>
{editing ? (
<Textarea
value={notes}
onChange={e => setNotes(e.target.value)}
placeholder="Add notes about this machine…"
className="text-xs min-h-16 resize-none"
/>
) : notes ? (
<p className="text-xs text-muted-foreground bg-muted/30 rounded p-2">{notes}</p>
) : (
<p className="text-xs text-muted-foreground italic">No notes</p>
)}
</div>
)}
{/* Edit controls */}
{editing && (
<div className="flex gap-2">
<Button size="sm" className="flex-1 h-7 text-xs" onClick={save} disabled={saving}>
<Check className="mr-1 h-3 w-3" />
{saving ? 'Saving…' : 'Save'}
</Button>
<Button size="sm" variant="ghost" className="h-7 text-xs" onClick={cancel}>
Cancel
</Button>
</div>
)}
{/* Connect */}
{!editing && (
<Button className="w-full" size="sm" disabled={!machine.isOnline} asChild={machine.isOnline}>
{machine.isOnline ? (
<Link href={`/dashboard/connect?machineId=${machine.id}`}>
<Link2 className="mr-2 h-4 w-4" />
Connect
</Link>
) : (
<>
<Link2 className="mr-2 h-4 w-4" />
Connect
</>
)}
</Button>
)}
</CardContent>
</Card>
)
}