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