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>
109 lines
3.1 KiB
TypeScript
109 lines
3.1 KiB
TypeScript
'use client'
|
|
|
|
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Search, X } from 'lucide-react'
|
|
import { useTransition } from 'react'
|
|
|
|
interface Group {
|
|
id: string
|
|
name: string
|
|
}
|
|
|
|
interface MachinesFilterProps {
|
|
allTags: string[]
|
|
groups: Group[]
|
|
currentSearch: string
|
|
currentTag: string
|
|
currentGroup: string
|
|
onlineCount: number
|
|
totalCount: number
|
|
}
|
|
|
|
export function MachinesFilter({
|
|
allTags,
|
|
groups,
|
|
currentSearch,
|
|
currentTag,
|
|
currentGroup,
|
|
onlineCount,
|
|
totalCount,
|
|
}: MachinesFilterProps) {
|
|
const router = useRouter()
|
|
const pathname = usePathname()
|
|
const searchParams = useSearchParams()
|
|
const [, startTransition] = useTransition()
|
|
|
|
const update = (key: string, value: string) => {
|
|
const params = new URLSearchParams(searchParams.toString())
|
|
if (value) params.set(key, value)
|
|
else params.delete(key)
|
|
startTransition(() => router.push(`${pathname}?${params.toString()}`))
|
|
}
|
|
|
|
const clearAll = () => {
|
|
startTransition(() => router.push(pathname))
|
|
}
|
|
|
|
const hasFilters = currentSearch || currentTag || currentGroup
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="flex flex-col sm:flex-row gap-2">
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Search machines…"
|
|
defaultValue={currentSearch}
|
|
onChange={e => update('q', e.target.value)}
|
|
className="pl-8"
|
|
/>
|
|
</div>
|
|
|
|
{groups.length > 0 && (
|
|
<select
|
|
value={currentGroup}
|
|
onChange={e => update('group', e.target.value)}
|
|
className="rounded-md border border-input bg-background px-3 py-2 text-sm"
|
|
>
|
|
<option value="">All groups</option>
|
|
{groups.map(g => (
|
|
<option key={g.id} value={g.id}>{g.name}</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground whitespace-nowrap">
|
|
<span className="text-green-500 font-medium">{onlineCount} online</span>
|
|
<span>/ {totalCount} total</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tag filter pills */}
|
|
{allTags.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5 items-center">
|
|
<span className="text-xs text-muted-foreground">Filter:</span>
|
|
{allTags.map(tag => (
|
|
<Badge
|
|
key={tag}
|
|
variant={currentTag === tag ? 'default' : 'outline'}
|
|
className="cursor-pointer text-xs h-5 py-0"
|
|
onClick={() => update('tag', currentTag === tag ? '' : tag)}
|
|
>
|
|
{tag}
|
|
</Badge>
|
|
))}
|
|
{hasFilters && (
|
|
<Button variant="ghost" size="sm" className="h-5 px-1.5 text-xs" onClick={clearAll}>
|
|
<X className="h-3 w-3 mr-1" />
|
|
Clear
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|