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>
281 lines
9.4 KiB
TypeScript
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>
|
|
)
|
|
}
|