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:
280
components/dashboard/machine-card.tsx
Normal file
280
components/dashboard/machine-card.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user