From 61edbf59bf8bf497cc793e06e02bc2f77f112a9e Mon Sep 17 00:00:00 2001 From: monoadmin Date: Fri, 10 Apr 2026 23:57:47 -0700 Subject: [PATCH] Add ScreenConnect-parity features (high + medium) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- agent/agent.py | 364 +++++++++++++++++++- agent/requirements.txt | 1 + app/(dashboard)/dashboard/admin/page.tsx | 126 ++++++- app/(dashboard)/dashboard/machines/page.tsx | 144 ++++---- app/api/agent/heartbeat/route.ts | 14 +- app/api/groups/route.ts | 44 +++ app/api/machines/[id]/route.ts | 34 ++ app/api/machines/wol/route.ts | 60 ++++ app/viewer/[sessionId]/page.tsx | 219 +++++++----- auth.ts | 2 +- components/dashboard/machine-card.tsx | 280 +++++++++++++++ components/dashboard/machines-filter.tsx | 108 ++++++ components/viewer/chat-panel.tsx | 105 ++++++ components/viewer/file-transfer-panel.tsx | 296 ++++++++++++++++ components/viewer/toolbar.tsx | 213 ++++++++---- db/migrations/0003_enhancements.sql | 17 + docker-compose.yml | 2 + lib/db/schema.ts | 16 + relay/main.py | 76 +++- tsconfig.tsbuildinfo | 1 + 20 files changed, 1881 insertions(+), 241 deletions(-) create mode 100644 app/api/groups/route.ts create mode 100644 app/api/machines/wol/route.ts create mode 100644 components/dashboard/machine-card.tsx create mode 100644 components/dashboard/machines-filter.tsx create mode 100644 components/viewer/chat-panel.tsx create mode 100644 components/viewer/file-transfer-panel.tsx create mode 100644 db/migrations/0003_enhancements.sql create mode 100644 tsconfig.tsbuildinfo diff --git a/agent/agent.py b/agent/agent.py index 23a38c4..0f5afc3 100644 --- a/agent/agent.py +++ b/agent/agent.py @@ -14,6 +14,7 @@ Usage: """ import asyncio +import base64 import json import logging import os @@ -21,6 +22,7 @@ import platform import subprocess import sys import time +import uuid as uuid_lib from io import BytesIO from pathlib import Path from typing import Optional @@ -126,6 +128,7 @@ async def register(server_url: str, enrollment_token: str) -> dict: "osVersion": os_version, "agentVersion": AGENT_VERSION, "ipAddress": None, + "macAddress": get_mac_address(), }) if resp.status_code != 200: raise RuntimeError(f"Registration failed: {resp.status_code} {resp.text}") @@ -135,18 +138,49 @@ async def register(server_url: str, enrollment_token: str) -> dict: async def heartbeat(server_url: str, access_key: str) -> Optional[dict]: - """Send heartbeat, returns pending connection info if any.""" + """Send heartbeat, returns server response including update info.""" url = f"{server_url.rstrip('/')}/api/agent/heartbeat" try: async with httpx.AsyncClient(timeout=10) as client: - resp = await client.post(url, json={"accessKey": access_key}) + resp = await client.post(url, json={"accessKey": access_key, "agentVersion": AGENT_VERSION}) if resp.status_code == 200: - return resp.json() + data = resp.json() + if data.get("updateAvailable"): + asyncio.create_task(_auto_update(data["updateAvailable"])) + return data except Exception as e: log.warning(f"Heartbeat failed: {e}") return None +async def _auto_update(update_info: dict) -> None: + """Download new agent binary and replace self, then restart.""" + download_url = update_info.get("downloadUrl") + if not download_url: + return + log.info(f"Update available: {update_info.get('version')}. Downloading from {download_url}") + try: + async with httpx.AsyncClient(timeout=120, follow_redirects=True) as client: + resp = await client.get(download_url) + if resp.status_code != 200: + log.warning(f"Update download failed: {resp.status_code}") + return + # Write to a temp file alongside the current binary + current = Path(sys.executable) + tmp = current.with_suffix(".new") + tmp.write_bytes(resp.content) + tmp.chmod(0o755) + # Atomically replace (Unix only — Windows needs a separate updater process) + if platform.system() != "Windows": + tmp.replace(current) + log.info(f"Agent updated to {update_info.get('version')}. Restarting…") + os.execv(str(current), sys.argv) + else: + log.info("Update downloaded. Restart the service to apply on Windows.") + except Exception as e: + log.warning(f"Auto-update failed: {e}") + + async def get_session_code(server_url: str, access_key: str) -> Optional[str]: """Request a new session code from the server.""" url = f"{server_url.rstrip('/')}/api/agent/session-code" @@ -166,13 +200,17 @@ class ScreenCapture: self.quality = quality self._frame_delay = 1.0 / fps self._sct = None + self._monitors: list = [] # mss monitors (excluding index 0 = all) + self._monitor_idx: int = 0 # 0-based into _monitors def __enter__(self): try: self._sct = mss() + self._monitors = self._sct.monitors[1:] # skip [0] = virtual all-screens except Exception as e: log.warning(f"Screen capture unavailable: {e}") self._sct = None + self._monitors = [] return self def __exit__(self, *args): @@ -183,11 +221,39 @@ class ScreenCapture: def available(self) -> bool: return self._sct is not None + def set_monitor(self, idx: int): + if 0 <= idx < len(self._monitors): + self._monitor_idx = idx + + def set_quality(self, quality: int): + self.quality = max(10, min(95, quality)) + fps_map = {"high": 15, "medium": 10, "low": 5} + # quality levels sent from viewer as strings + if isinstance(quality, str): + self.fps = fps_map.get(quality, 15) + self.quality = {"high": 70, "medium": 55, "low": 35}.get(quality, 70) + else: + self.quality = max(10, min(95, quality)) + self._frame_delay = 1.0 / self.fps + + def get_monitor_list(self) -> list: + return [ + { + "index": i, + "width": m["width"], + "height": m["height"], + "left": m["left"], + "top": m["top"], + "primary": i == 0, + } + for i, m in enumerate(self._monitors) + ] + def capture(self) -> Optional[bytes]: - if not self._sct: + if not self._sct or not self._monitors: return None try: - monitor = self._sct.monitors[1] # Primary monitor + monitor = self._monitors[self._monitor_idx] img = self._sct.grab(monitor) pil = Image.frombytes("RGB", img.size, img.bgra, "raw", "BGRX") buf = BytesIO() @@ -239,10 +305,8 @@ class InputController: elif t == "mouse_scroll": self._mouse.scroll(event.get("dx", 0), event.get("dy", 0)) elif t == "key_press": - key_str = event.get("key", "") - self._keyboard.type(key_str) + self._keyboard.type(event.get("key", "")) elif t == "key_special": - # Special keys like Enter, Tab, Escape, etc. key_name = event.get("key", "") try: key = getattr(self._Key, key_name) @@ -250,6 +314,19 @@ class InputController: self._keyboard.release(key) except AttributeError: pass + elif t == "exec_key_combo": + # e.g. {"type":"exec_key_combo","keys":["ctrl_l","alt_l","delete"]} + keys = event.get("keys", []) + pressed = [] + for k in keys: + try: + key_obj = getattr(self._Key, k) + self._keyboard.press(key_obj) + pressed.append(key_obj) + except AttributeError: + pass + for key_obj in reversed(pressed): + self._keyboard.release(key_obj) except Exception as e: log.debug(f"Input error: {e}") @@ -301,6 +378,157 @@ async def exec_script(script: str, shell: str, session_id: str, ws) -> None: })) +# ── Clipboard helpers ───────────────────────────────────────────────────────── + +def _get_clipboard() -> str: + try: + import pyperclip + return pyperclip.paste() or "" + except Exception: + return "" + + +def _set_clipboard(text: str): + try: + import pyperclip + pyperclip.copy(text) + except Exception: + pass + + +# ── File transfer helpers ───────────────────────────────────────────────────── + +CHUNK_SIZE = 65536 # 64 KB per chunk + + +async def send_file(path_str: str, session_id: str, ws) -> None: + """Read a file and send it to the viewer as base64 chunks.""" + try: + path = Path(path_str).expanduser().resolve() + if not path.is_file(): + await ws.send(json.dumps({ + "type": "file_chunk", "session_id": session_id, + "error": f"File not found: {path_str}", "done": True, + })) + return + total = path.stat().st_size + filename = path.name + seq = 0 + with open(path, "rb") as f: + while True: + chunk = f.read(CHUNK_SIZE) + if not chunk: + break + await ws.send(json.dumps({ + "type": "file_chunk", + "session_id": session_id, + "filename": filename, + "size": total, + "seq": seq, + "chunk": base64.b64encode(chunk).decode(), + "done": False, + })) + seq += 1 + await ws.send(json.dumps({ + "type": "file_chunk", + "session_id": session_id, + "filename": filename, + "size": total, + "seq": seq, + "chunk": "", + "done": True, + })) + log.info(f"Sent file {filename} ({total} bytes) to viewer") + except Exception as e: + await ws.send(json.dumps({ + "type": "file_chunk", "session_id": session_id, + "error": str(e), "done": True, + })) + + +async def list_files(path_str: str, session_id: str, ws) -> None: + """Send a directory listing to the viewer.""" + try: + path = Path(path_str or "~").expanduser().resolve() + if not path.is_dir(): + path = path.parent + entries = [] + for entry in sorted(path.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())): + try: + stat = entry.stat() + entries.append({ + "name": entry.name, + "is_dir": entry.is_dir(), + "size": stat.st_size if entry.is_file() else 0, + "path": str(entry), + }) + except PermissionError: + pass + await ws.send(json.dumps({ + "type": "file_list", + "session_id": session_id, + "path": str(path), + "parent": str(path.parent) if path.parent != path else None, + "entries": entries, + })) + except Exception as e: + await ws.send(json.dumps({ + "type": "file_list", "session_id": session_id, + "path": path_str, "entries": [], "error": str(e), + })) + + +async def _prompt_user_consent(timeout: int = 30) -> bool: + """Show a local dialog asking the user to accept the incoming connection. + Returns True if accepted within timeout, False otherwise.""" + try: + if platform.system() == "Windows": + # Use PowerShell MessageBox + script = ( + "[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms') | Out-Null;" + "$r = [System.Windows.Forms.MessageBox]::Show(" + "'A technician is requesting remote access to this computer. Allow?'," + "'RemoteLink — Incoming Connection'," + "[System.Windows.Forms.MessageBoxButtons]::YesNo," + "[System.Windows.Forms.MessageBoxIcon]::Question);" + "exit ($r -eq 'Yes' ? 0 : 1)" + ) + proc = await asyncio.create_subprocess_exec( + "powershell", "-NonInteractive", "-Command", script, + ) + try: + await asyncio.wait_for(proc.wait(), timeout=timeout) + return proc.returncode == 0 + except asyncio.TimeoutError: + proc.kill() + return False + elif platform.system() == "Linux": + # Try zenity (GNOME), then kdialog (KDE), then xterm fallback + for cmd in [ + ["zenity", "--question", "--title=RemoteLink", f"--text=A technician is requesting remote access. Allow?", f"--timeout={timeout}"], + ["kdialog", "--yesno", "A technician is requesting remote access. Allow?", "--title", "RemoteLink"], + ]: + try: + proc = await asyncio.create_subprocess_exec(*cmd) + await asyncio.wait_for(proc.wait(), timeout=timeout + 5) + return proc.returncode == 0 + except (FileNotFoundError, asyncio.TimeoutError): + continue + # No GUI available — log and deny + log.warning("Attended mode: no GUI dialog available, denying connection") + return False + else: + return True # macOS: always allow for now + except Exception as e: + log.warning(f"Consent dialog error: {e}") + return False + + +def get_mac_address() -> str: + mac = uuid_lib.getnode() + return ":".join(f"{(mac >> (8 * i)) & 0xff:02X}" for i in reversed(range(6))) + + # ── Main agent loop ─────────────────────────────────────────────────────────── class Agent: @@ -314,6 +542,10 @@ class Agent: self._active_session: Optional[str] = None self._input = InputController() self._stop_event = asyncio.Event() + self._screen: Optional[ScreenCapture] = None + self._attended_mode: bool = False # set via --attended flag + # file upload buffers: transfer_id → {filename, dest, chunks[]} + self._uploads: dict = {} async def run(self): log.info(f"Agent starting. Machine ID: {self.machine_id}") @@ -344,8 +576,28 @@ class Agent: log.info("Connected to relay") await self._message_loop(ws) + async def _clipboard_poll(self, ws): + """Poll local clipboard every 2s; send changes to active viewer.""" + last = _get_clipboard() + while not self._stop_event.is_set(): + await asyncio.sleep(2) + if not self._active_session: + continue + current = _get_clipboard() + if current != last and current: + last = current + try: + await ws.send(json.dumps({ + "type": "clipboard_content", + "session_id": self._active_session, + "content": current, + })) + except Exception: + break + async def _message_loop(self, ws): with ScreenCapture(fps=15, quality=60) as screen: + self._screen = screen if not screen.available: log.warning( "Screen capture unavailable — agent will stay connected " @@ -353,6 +605,7 @@ class Agent: ) stream_task: Optional[asyncio.Task] = None + clipboard_task: Optional[asyncio.Task] = None async def stream_frames(): while self._streaming and not self._stop_event.is_set(): @@ -382,17 +635,38 @@ class Agent: if msg_type == "start_stream": session_id = msg.get("session_id") log.info(f"Viewer connected — session {session_id}") + + # Attended mode: ask local user for consent + if self._attended_mode: + accepted = await _prompt_user_consent() + if not accepted: + await ws.send(json.dumps({ + "type": "error", + "session_id": session_id, + "message": "Remote session denied by local user.", + })) + continue + self._streaming = True self._active_session = session_id if not screen.available: - # Tell viewer why they won't see frames await ws.send(json.dumps({ "type": "error", - "message": "Screen capture unavailable on this machine (no display). Set $DISPLAY and restart the agent.", + "message": "Screen capture unavailable (no display). Set $DISPLAY and restart the agent.", })) + # Send monitor list to viewer + await ws.send(json.dumps({ + "type": "monitor_list", + "session_id": session_id, + "monitors": screen.get_monitor_list(), + })) if stream_task and not stream_task.done(): stream_task.cancel() stream_task = asyncio.create_task(stream_frames()) + # Start clipboard polling + if clipboard_task and not clipboard_task.done(): + clipboard_task.cancel() + clipboard_task = asyncio.create_task(self._clipboard_poll(ws)) elif msg_type == "stop_stream": log.info("Viewer disconnected — stopping stream") @@ -400,11 +674,28 @@ class Agent: self._active_session = None if stream_task and not stream_task.done(): stream_task.cancel() + if clipboard_task and not clipboard_task.done(): + clipboard_task.cancel() + + elif msg_type == "set_monitor": + screen.set_monitor(msg.get("index", 0)) + log.info(f"Switched to monitor {msg.get('index', 0)}") + + elif msg_type == "set_quality": + q = msg.get("quality", "high") + quality_map = {"high": 70, "medium": 55, "low": 35} + fps_map = {"high": 15, "medium": 10, "low": 5} + screen.quality = quality_map.get(q, 70) + screen.fps = fps_map.get(q, 15) + screen._frame_delay = 1.0 / screen.fps elif msg_type in ("mouse_move", "mouse_click", "mouse_scroll", - "key_press", "key_special"): + "key_press", "key_special", "exec_key_combo"): self._input.handle(msg) + elif msg_type == "clipboard_paste": + _set_clipboard(msg.get("content", "")) + elif msg_type == "exec_script": asyncio.create_task(exec_script( msg.get("script", ""), @@ -413,13 +704,62 @@ class Agent: ws, )) + elif msg_type == "file_download": + asyncio.create_task(send_file( + msg.get("path", ""), + msg.get("session_id", self._active_session or ""), + ws, + )) + + elif msg_type == "list_files": + asyncio.create_task(list_files( + msg.get("path", "~"), + msg.get("session_id", self._active_session or ""), + ws, + )) + + elif msg_type == "file_upload_start": + tid = msg.get("transfer_id", "") + filename = Path(msg.get("filename", "upload")).name + dest = Path(msg.get("dest_path", "~")).expanduser() / filename + self._uploads[tid] = {"dest": dest, "chunks": []} + log.info(f"File upload starting: {filename} → {dest}") + + elif msg_type == "file_upload_chunk": + tid = msg.get("transfer_id", "") + if tid in self._uploads: + chunk = base64.b64decode(msg.get("chunk", "")) + self._uploads[tid]["chunks"].append(chunk) + + elif msg_type == "file_upload_end": + tid = msg.get("transfer_id", "") + if tid in self._uploads: + info = self._uploads.pop(tid) + dest: Path = info["dest"] + dest.parent.mkdir(parents=True, exist_ok=True) + with open(dest, "wb") as f: + for chunk in info["chunks"]: + f.write(chunk) + log.info(f"File upload complete: {dest}") + await ws.send(json.dumps({ + "type": "file_chunk", + "session_id": self._active_session or "", + "transfer_id": tid, + "done": True, + "upload_complete": True, + "path": str(dest), + })) + elif msg_type == "ping": await ws.send(json.dumps({"type": "pong"})) finally: self._streaming = False + self._screen = None if stream_task and not stream_task.done(): stream_task.cancel() + if clipboard_task and not clipboard_task.done(): + clipboard_task.cancel() def stop(self): self._stop_event.set() @@ -466,6 +806,7 @@ async def main(): parser.add_argument("--relay", help="Relay WebSocket URL (e.g. ws://remotelink.example.com:8765)") parser.add_argument("--enroll", metavar="TOKEN", help="Enrollment token for first-time registration") parser.add_argument("--run-once", action="store_true", help="Exit after first session ends") + parser.add_argument("--attended", action="store_true", help="Prompt local user to accept each incoming connection") parser.add_argument("--verbose", "-v", action="store_true") args = parser.parse_args() @@ -503,6 +844,7 @@ async def main(): access_key = config["access_key"] agent = Agent(server_url, machine_id, access_key, relay_url) + agent._attended_mode = args.attended # Handle Ctrl+C / SIGTERM gracefully loop = asyncio.get_event_loop() diff --git a/agent/requirements.txt b/agent/requirements.txt index 49b672d..9aa6605 100644 --- a/agent/requirements.txt +++ b/agent/requirements.txt @@ -4,6 +4,7 @@ websockets==13.1 mss==9.0.2 Pillow==11.0.0 pynput==1.7.7 +pyperclip==1.9.0 # Windows service support (Windows only) pywin32==308; sys_platform == "win32" diff --git a/app/(dashboard)/dashboard/admin/page.tsx b/app/(dashboard)/dashboard/admin/page.tsx index c202f2b..558edfa 100644 --- a/app/(dashboard)/dashboard/admin/page.tsx +++ b/app/(dashboard)/dashboard/admin/page.tsx @@ -13,7 +13,7 @@ import { } from '@/components/ui/card' import { UserPlus, Copy, Check, Trash2, Clock, CheckCircle2, - Loader2, KeyRound, Ban, Infinity, Hash, + Loader2, KeyRound, Ban, Infinity, Hash, FolderOpen, Plus, } from 'lucide-react' // ── Shared helper ───────────────────────────────────────────────────────────── @@ -421,23 +421,135 @@ function EnrollmentSection() { ) } +// ── Groups ──────────────────────────────────────────────────────────────────── + +interface Group { + id: string + name: string + description: string | null + created_at: string +} + +function GroupsSection() { + const [groups, setGroups] = useState([]) + const [name, setName] = useState('') + const [description, setDescription] = useState('') + const [isLoading, setIsLoading] = useState(true) + const [isCreating, setIsCreating] = useState(false) + + const fetchGroups = useCallback(async () => { + const res = await fetch('/api/groups') + const data = await res.json() + if (res.ok) setGroups(data.groups ?? []) + setIsLoading(false) + }, []) + + useEffect(() => { fetchGroups() }, [fetchGroups]) + + const handleCreate = async (e: React.SyntheticEvent) => { + e.preventDefault() + if (!name.trim()) return + setIsCreating(true) + await fetch('/api/groups', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, description }), + }) + setName(''); setDescription('') + await fetchGroups() + setIsCreating(false) + } + + const handleDelete = async (id: string) => { + await fetch('/api/groups', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }), + }) + await fetchGroups() + } + + return ( +
+ + + Create Group + Groups let you organise machines and control which technicians have access to them. + + +
+
+ + setName(e.target.value)} placeholder="e.g. Client — Acme Corp" className="mt-1" /> +
+
+ + setDescription(e.target.value)} placeholder="Optional description" className="mt-1" /> +
+ +
+
+
+ + + + Groups + + + {isLoading ? ( +
Loading…
+ ) : groups.length === 0 ? ( +

No groups yet. Create one above.

+ ) : ( +
+ {groups.map(g => ( +
+
+ +
+

{g.name}

+ {g.description &&

{g.description}

} +
+
+ +
+ ))} +
+ )} +
+
+
+ ) +} + // ── Page ────────────────────────────────────────────────────────────────────── -type Tab = 'invites' | 'enrollment' +type Tab = 'invites' | 'enrollment' | 'groups' export default function AdminPage() { const [tab, setTab] = useState('invites') + const tabLabels: Record = { + invites: 'User invites', + enrollment: 'Agent enrollment', + groups: 'Groups', + } + return (

Admin

-

Manage users and agent deployment

+

Manage users, agent deployment, and machine groups

{/* Tab bar */}
- {(['invites', 'enrollment'] as Tab[]).map((t) => ( + {(['invites', 'enrollment', 'groups'] as Tab[]).map((t) => ( ))}
- {tab === 'invites' ? : } + {tab === 'invites' && } + {tab === 'enrollment' && } + {tab === 'groups' && }
) } diff --git a/app/(dashboard)/dashboard/machines/page.tsx b/app/(dashboard)/dashboard/machines/page.tsx index 3782ad2..22d599a 100644 --- a/app/(dashboard)/dashboard/machines/page.tsx +++ b/app/(dashboard)/dashboard/machines/page.tsx @@ -1,32 +1,63 @@ import { auth } from '@/auth' import { db } from '@/lib/db' -import { machines } from '@/lib/db/schema' -import { eq, desc } from 'drizzle-orm' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { machines, groups } from '@/lib/db/schema' +import { eq, desc, or, ilike, sql } from 'drizzle-orm' import { Button } from '@/components/ui/button' +import { Card, CardContent } from '@/components/ui/card' import Link from 'next/link' -import { Download, Laptop, Link2 } from 'lucide-react' -import { formatDistanceToNow } from 'date-fns' -import { MachineActions } from '@/components/dashboard/machine-actions' +import { Download, Laptop } from 'lucide-react' +import { MachineCard } from '@/components/dashboard/machine-card' +import { MachinesFilter } from '@/components/dashboard/machines-filter' -export default async function MachinesPage() { +interface PageProps { + searchParams: Promise<{ q?: string; tag?: string; group?: string }> +} + +export default async function MachinesPage({ searchParams }: PageProps) { const session = await auth() - const machineList = await db + const { q, tag, group } = await searchParams + + // Build query conditions + const conditions = [eq(machines.userId, session!.user.id)] + if (group) conditions.push(eq(machines.groupId, group)) + + let machineList = await db .select() .from(machines) - .where(eq(machines.userId, session!.user.id)) + .where(conditions.length === 1 ? conditions[0] : sql`${conditions.reduce((a, b) => sql`${a} AND ${b}`)}`) .orderBy(desc(machines.isOnline), desc(machines.lastSeen)) - const onlineCount = machineList.filter((m) => m.isOnline).length + // Text search (filter in JS — avoids complex SQL for small datasets) + if (q) { + const lower = q.toLowerCase() + machineList = machineList.filter(m => + m.name.toLowerCase().includes(lower) || + (m.hostname ?? '').toLowerCase().includes(lower) || + (m.os ?? '').toLowerCase().includes(lower) || + (m.ipAddress ?? '').toLowerCase().includes(lower) + ) + } + + // Tag filter + if (tag) { + machineList = machineList.filter(m => m.tags?.includes(tag)) + } + + // Collect all unique tags across machines + const allTagsSet = new Set() + machineList.forEach(m => (m.tags ?? []).forEach(t => allTagsSet.add(t))) + const allTags = [...allTagsSet].sort() + + const onlineCount = machineList.filter(m => m.isOnline).length + + // Fetch groups for group picker and machine card selects + const groupList = await db.select({ id: groups.id, name: groups.name }).from(groups).orderBy(groups.name) return (

Machines

-

- {machineList.length} machine{machineList.length !== 1 ? 's' : ''} registered, {onlineCount} online -

+ + {machineList.length > 0 ? (
{machineList.map((machine) => ( - - -
-
- -
-
- {machine.name} - {machine.hostname || 'Unknown host'} -
-
- -
- -
- Status - - - {machine.isOnline ? 'Online' : 'Offline'} - -
-
- OS - {machine.os || 'Unknown'} {machine.osVersion || ''} -
-
- Last seen - - {machine.lastSeen - ? formatDistanceToNow(new Date(machine.lastSeen), { addSuffix: true }) - : 'Never'} - -
-
- Agent - {machine.agentVersion || 'N/A'} -
-
- -
-
-
+ ))}
) : ( -

No machines yet

+

+ {q || tag || group ? 'No machines match your filter' : 'No machines yet'} +

- Download and install the RemoteLink agent on the machines you want to control remotely. + {q || tag || group + ? 'Try adjusting your search or filters.' + : 'Download and install the RemoteLink agent on the machines you want to control remotely.'}

- + {!(q || tag || group) && ( + + )}
)} diff --git a/app/api/agent/heartbeat/route.ts b/app/api/agent/heartbeat/route.ts index 8065117..203e023 100644 --- a/app/api/agent/heartbeat/route.ts +++ b/app/api/agent/heartbeat/route.ts @@ -3,9 +3,12 @@ import { machines, sessionCodes } from '@/lib/db/schema' import { eq, and, isNotNull, gt } from 'drizzle-orm' import { NextRequest, NextResponse } from 'next/server' +// Increment this when a new agent binary is published to /downloads/ +const CURRENT_AGENT_VERSION = '1.0.0' + export async function POST(request: NextRequest) { try { - const { accessKey } = await request.json() + const { accessKey, agentVersion } = await request.json() if (!accessKey) { return NextResponse.json({ error: 'Access key required' }, { status: 400 }) @@ -13,7 +16,7 @@ export async function POST(request: NextRequest) { const result = await db .update(machines) - .set({ isOnline: true, lastSeen: new Date() }) + .set({ isOnline: true, lastSeen: new Date(), agentVersion: agentVersion || undefined }) .where(eq(machines.accessKey, accessKey)) .returning({ id: machines.id }) @@ -38,11 +41,18 @@ export async function POST(request: NextRequest) { .orderBy(sessionCodes.usedAt) .limit(1) + const needsUpdate = agentVersion && agentVersion !== CURRENT_AGENT_VERSION + const appUrl = process.env.NEXT_PUBLIC_APP_URL || '' + const downloadUrl = needsUpdate + ? `${appUrl}/downloads/remotelink-agent-${process.platform === 'win32' ? 'windows.exe' : 'linux'}` + : null + return NextResponse.json({ success: true, pendingConnection: pending[0] ? { sessionCodeId: pending[0].id, usedBy: pending[0].usedBy } : null, + updateAvailable: needsUpdate ? { version: CURRENT_AGENT_VERSION, downloadUrl } : null, }) } catch (error) { console.error('[Heartbeat] Error:', error) diff --git a/app/api/groups/route.ts b/app/api/groups/route.ts new file mode 100644 index 0000000..aa6b524 --- /dev/null +++ b/app/api/groups/route.ts @@ -0,0 +1,44 @@ +import { auth } from '@/auth' +import { db } from '@/lib/db' +import { groups } from '@/lib/db/schema' +import { eq } from 'drizzle-orm' +import { NextRequest, NextResponse } from 'next/server' + +type AuthUser = { id: string; role?: string } + +export async function GET() { + const authSession = await auth() + if (!authSession?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const list = await db.select().from(groups).orderBy(groups.name) + return NextResponse.json({ groups: list }) +} + +export async function POST(request: NextRequest) { + const authSession = await auth() + if (!authSession?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const u = authSession.user as AuthUser + if (u.role !== 'admin') return NextResponse.json({ error: 'Admin only' }, { status: 403 }) + + const { name, description } = await request.json() + if (!name?.trim()) return NextResponse.json({ error: 'Name required' }, { status: 400 }) + + const [group] = await db.insert(groups).values({ + name: String(name).slice(0, 100), + description: description ? String(description).slice(0, 500) : null, + createdBy: u.id, + }).returning() + + return NextResponse.json({ group }) +} + +export async function DELETE(request: NextRequest) { + const authSession = await auth() + if (!authSession?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const u = authSession.user as AuthUser + if (u.role !== 'admin') return NextResponse.json({ error: 'Admin only' }, { status: 403 }) + + const { id } = await request.json() + await db.delete(groups).where(eq(groups.id, id)) + return NextResponse.json({ success: true }) +} diff --git a/app/api/machines/[id]/route.ts b/app/api/machines/[id]/route.ts index 1871aea..aee64d6 100644 --- a/app/api/machines/[id]/route.ts +++ b/app/api/machines/[id]/route.ts @@ -4,6 +4,40 @@ import { machines } from '@/lib/db/schema' import { eq, and } from 'drizzle-orm' import { NextRequest, NextResponse } from 'next/server' +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const { id } = await params + const body = await request.json() + + const updates: Record = {} + if (body.name !== undefined) updates.name = String(body.name).slice(0, 255) + if (body.notes !== undefined) updates.notes = body.notes ? String(body.notes) : null + if (body.tags !== undefined) updates.tags = Array.isArray(body.tags) ? body.tags.map(String) : [] + if (body.groupId !== undefined) updates.groupId = body.groupId || null + + if (Object.keys(updates).length === 0) { + return NextResponse.json({ error: 'Nothing to update' }, { status: 400 }) + } + + const result = await db + .update(machines) + .set(updates) + .where(and(eq(machines.id, id), eq(machines.userId, session.user.id))) + .returning({ id: machines.id }) + + if (!result[0]) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + return NextResponse.json({ success: true }) +} + export async function DELETE( _request: NextRequest, { params }: { params: Promise<{ id: string }> } diff --git a/app/api/machines/wol/route.ts b/app/api/machines/wol/route.ts new file mode 100644 index 0000000..f562b84 --- /dev/null +++ b/app/api/machines/wol/route.ts @@ -0,0 +1,60 @@ +import { auth } from '@/auth' +import { db } from '@/lib/db' +import { machines } from '@/lib/db/schema' +import { and, eq } from 'drizzle-orm' +import { NextRequest, NextResponse } from 'next/server' +import { createSocket } from 'dgram' + +function buildMagicPacket(mac: string): Buffer { + // Normalise: strip separators, expect 12 hex chars + const hex = mac.replace(/[:\-]/g, '').toLowerCase() + if (hex.length !== 12 || !/^[0-9a-f]+$/.test(hex)) { + throw new Error('Invalid MAC address') + } + const macBytes = Buffer.from(hex, 'hex') + // Magic packet: 6x 0xFF + 16x MAC + const packet = Buffer.alloc(6 + 16 * 6) + packet.fill(0xff, 0, 6) + for (let i = 0; i < 16; i++) macBytes.copy(packet, 6 + i * 6) + return packet +} + +async function sendMagicPacket(mac: string): Promise { + const packet = buildMagicPacket(mac) + return new Promise((resolve, reject) => { + const sock = createSocket('udp4') + sock.once('error', reject) + sock.bind(() => { + sock.setBroadcast(true) + sock.send(packet, 0, packet.length, 9, '255.255.255.255', () => { + sock.close() + resolve() + }) + }) + }) +} + +export async function POST(request: NextRequest) { + const session = await auth() + if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const { machineId } = await request.json() + if (!machineId) return NextResponse.json({ error: 'machineId required' }, { status: 400 }) + + const result = await db + .select({ macAddress: machines.macAddress, name: machines.name }) + .from(machines) + .where(and(eq(machines.id, machineId), eq(machines.userId, session.user.id))) + .limit(1) + + const machine = result[0] + if (!machine) return NextResponse.json({ error: 'Machine not found' }, { status: 404 }) + if (!machine.macAddress) return NextResponse.json({ error: 'No MAC address recorded for this machine. Agent must reconnect at least once.' }, { status: 422 }) + + try { + await sendMagicPacket(machine.macAddress) + return NextResponse.json({ success: true, message: `Magic packet sent to ${machine.macAddress}` }) + } catch (e) { + return NextResponse.json({ error: String(e) }, { status: 500 }) + } +} diff --git a/app/viewer/[sessionId]/page.tsx b/app/viewer/[sessionId]/page.tsx index 2ce24b0..2071d2e 100644 --- a/app/viewer/[sessionId]/page.tsx +++ b/app/viewer/[sessionId]/page.tsx @@ -3,9 +3,11 @@ import { useEffect, useState, useRef, useCallback } from 'react' import { useParams, useRouter, useSearchParams } from 'next/navigation' import { Button } from '@/components/ui/button' -import { Maximize2, Minimize2, Monitor, Loader2, AlertCircle, WifiOff } from 'lucide-react' +import { Monitor, Loader2, AlertCircle, WifiOff } from 'lucide-react' import { ViewerToolbar } from '@/components/viewer/toolbar' import { ConnectionStatus } from '@/components/viewer/connection-status' +import { FileTransferPanel } from '@/components/viewer/file-transfer-panel' +import { ChatPanel } from '@/components/viewer/chat-panel' interface Session { id: string @@ -16,6 +18,13 @@ interface Session { connectionType: string | null } +interface MonitorInfo { + index: number + width: number + height: number + primary: boolean +} + type ConnectionState = 'connecting' | 'waiting' | 'connected' | 'disconnected' | 'error' export default function ViewerPage() { @@ -23,7 +32,6 @@ export default function ViewerPage() { const searchParams = useSearchParams() const router = useRouter() const sessionId = params.sessionId as string - // viewerToken is passed as a query param from the connect flow const viewerToken = searchParams.get('token') const [session, setSession] = useState(null) @@ -31,46 +39,43 @@ export default function ViewerPage() { const [isFullscreen, setIsFullscreen] = useState(false) const [showToolbar, setShowToolbar] = useState(true) const [quality, setQuality] = useState<'high' | 'medium' | 'low'>('high') - const [isMuted, setIsMuted] = useState(true) const [error, setError] = useState(null) const [statusMsg, setStatusMsg] = useState('Connecting to relay…') const [fps, setFps] = useState(0) + const [monitors, setMonitors] = useState([]) + const [activeMonitor, setActiveMonitor] = useState(0) + const [showFileTransfer, setShowFileTransfer] = useState(false) + const [showChat, setShowChat] = useState(false) + const [lastAgentMsg, setLastAgentMsg] = useState | null>(null) const containerRef = useRef(null) const canvasRef = useRef(null) const wsRef = useRef(null) const fpsCounterRef = useRef({ frames: 0, lastTime: Date.now() }) const reconnectTimerRef = useRef | null>(null) + const connectionStateRef = useRef('connecting') + + // Keep ref in sync so closures can read current value + useEffect(() => { connectionStateRef.current = connectionState }, [connectionState]) // ── Load session info ────────────────────────────────────────────────────── useEffect(() => { fetch(`/api/sessions/${sessionId}`) .then((r) => r.json()) .then((data) => { - if (!data.session) { - setError('Session not found') - setConnectionState('error') - return - } - if (data.session.endedAt) { - setError('This session has ended') - setConnectionState('disconnected') - return - } + if (!data.session) { setError('Session not found'); setConnectionState('error'); return } + if (data.session.endedAt) { setError('This session has ended'); setConnectionState('disconnected'); return } setSession(data.session) }) - .catch(() => { - setError('Failed to load session') - setConnectionState('error') - }) + .catch(() => { setError('Failed to load session'); setConnectionState('error') }) }, [sessionId]) // ── WebSocket connection ─────────────────────────────────────────────────── const connectWS = useCallback(() => { if (!viewerToken) return + if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current); reconnectTimerRef.current = null } - const relayHost = process.env.NEXT_PUBLIC_RELAY_URL || - `${window.location.hostname}:8765` + const relayHost = process.env.NEXT_PUBLIC_RELAY_URL || `${window.location.hostname}:8765` const proto = window.location.protocol === 'https:' ? 'wss' : 'ws' const wsUrl = `${proto}://${relayHost}/ws/viewer?session_id=${sessionId}&viewer_token=${viewerToken}` @@ -81,14 +86,10 @@ export default function ViewerPage() { ws.binaryType = 'arraybuffer' wsRef.current = ws - ws.onopen = () => { - setStatusMsg('Waiting for agent…') - setConnectionState('waiting') - } + ws.onopen = () => { setStatusMsg('Waiting for agent…'); setConnectionState('waiting') } ws.onmessage = (evt) => { if (evt.data instanceof ArrayBuffer) { - // Binary = JPEG frame renderFrame(evt.data) } else { try { @@ -100,22 +101,18 @@ export default function ViewerPage() { ws.onclose = (evt) => { wsRef.current = null - if (connectionState !== 'disconnected' && evt.code !== 4001) { + if (connectionStateRef.current !== 'disconnected' && evt.code !== 4001) { setConnectionState('waiting') setStatusMsg('Connection lost — reconnecting…') reconnectTimerRef.current = setTimeout(connectWS, 3000) } } - ws.onerror = () => { - setStatusMsg('Relay connection failed') - } - }, [sessionId, viewerToken]) + ws.onerror = () => { setStatusMsg('Relay connection failed') } + }, [sessionId, viewerToken]) // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { - if (session && viewerToken) { - connectWS() - } + if (session && viewerToken) connectWS() return () => { wsRef.current?.close() if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current) @@ -136,6 +133,18 @@ export default function ViewerPage() { setConnectionState('waiting') setStatusMsg('Agent disconnected — waiting for reconnect…') break + case 'monitor_list': + setMonitors((msg.monitors as MonitorInfo[]) ?? []) + break + case 'clipboard_content': + // Write agent clipboard to browser clipboard + navigator.clipboard.writeText(msg.content as string).catch(() => {}) + break + case 'file_chunk': + case 'file_list': + case 'chat_message': + setLastAgentMsg(msg) + break } } @@ -143,7 +152,6 @@ export default function ViewerPage() { const renderFrame = useCallback((data: ArrayBuffer) => { const canvas = canvasRef.current if (!canvas) return - const blob = new Blob([data], { type: 'image/jpeg' }) const url = URL.createObjectURL(blob) const img = new Image() @@ -156,8 +164,6 @@ export default function ViewerPage() { } ctx.drawImage(img, 0, 0) URL.revokeObjectURL(url) - - // FPS counter const counter = fpsCounterRef.current counter.frames++ const now = Date.now() @@ -195,12 +201,7 @@ export default function ViewerPage() { const handleMouseClick = useCallback((e: React.MouseEvent) => { if (connectionState !== 'connected') return const { x, y } = getCanvasCoords(e) - sendEvent({ - type: 'mouse_click', - button: e.button === 2 ? 'right' : 'left', - double: e.detail === 2, - x, y, - }) + sendEvent({ type: 'mouse_click', button: e.button === 2 ? 'right' : 'left', double: e.detail === 2, x, y }) }, [connectionState, sendEvent, getCanvasCoords]) const handleMouseScroll = useCallback((e: React.WheelEvent) => { @@ -229,6 +230,30 @@ export default function ViewerPage() { return () => document.removeEventListener('keydown', handleKeyDown) }, [handleKeyDown]) + // ── Toolbar actions ──────────────────────────────────────────────────────── + const handleCtrlAltDel = useCallback(() => { + sendEvent({ type: 'exec_key_combo', keys: ['ctrl_l', 'alt_l', 'delete'] }) + }, [sendEvent]) + + const handleClipboardPaste = useCallback(async () => { + try { + const text = await navigator.clipboard.readText() + sendEvent({ type: 'clipboard_paste', content: text }) + } catch { + // clipboard permission denied — ignore + } + }, [sendEvent]) + + const handleMonitorChange = useCallback((index: number) => { + setActiveMonitor(index) + sendEvent({ type: 'set_monitor', index }) + }, [sendEvent]) + + const handleQualityChange = useCallback((q: 'high' | 'medium' | 'low') => { + setQuality(q) + sendEvent({ type: 'set_quality', quality: q }) + }, [sendEvent]) + // ── Fullscreen / toolbar auto-hide ───────────────────────────────────────── const toggleFullscreen = async () => { if (!containerRef.current) return @@ -245,8 +270,10 @@ export default function ViewerPage() { if (isFullscreen && connectionState === 'connected') { const timer = setTimeout(() => setShowToolbar(false), 3000) return () => clearTimeout(timer) + } else { + setShowToolbar(true) } - }, [isFullscreen, connectionState, showToolbar]) + }, [isFullscreen, connectionState]) // ── End session ──────────────────────────────────────────────────────────── const endSession = async () => { @@ -282,61 +309,91 @@ export default function ViewerPage() { className="min-h-svh bg-black flex flex-col" onMouseMove={() => isFullscreen && setShowToolbar(true)} > -
+
{ - setQuality(q) - sendEvent({ type: 'set_quality', quality: q }) - }} - onToggleMute={() => setIsMuted(!isMuted)} + onQualityChange={handleQualityChange} + onToggleMute={() => {}} onDisconnect={endSession} onReconnect={connectWS} + onCtrlAltDel={handleCtrlAltDel} + onClipboardPaste={handleClipboardPaste} + onMonitorChange={handleMonitorChange} + onToggleFileTransfer={() => setShowFileTransfer(v => !v)} + onToggleChat={() => setShowChat(v => !v)} + onWakeOnLan={session?.machineId ? async () => { + await fetch('/api/machines/wol', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ machineId: session.machineId }), + }) + } : undefined} />
-
- {(connectionState === 'connecting' || connectionState === 'waiting') && ( -
-
- {connectionState === 'waiting' - ? - : } -
-

{statusMsg}

-

{session?.machineName}

+
+ {/* Main canvas area */} +
+ {(connectionState === 'connecting' || connectionState === 'waiting') && ( +
+
+ {connectionState === 'waiting' + ? + : } +
+

{statusMsg}

+

{session?.machineName}

+
-
- )} + )} - { e.preventDefault(); handleMouseClick(e) }} - /> + { e.preventDefault(); handleMouseClick(e) }} + /> - {connectionState === 'disconnected' && ( -
- -
-

Session Ended

-

The remote connection has been closed

+ {connectionState === 'disconnected' && ( +
+ +
+

Session Ended

+

The remote connection has been closed

+
+
- -
+ )} +
+ + {/* Sidebars */} + {showFileTransfer && connectionState === 'connected' && ( + setShowFileTransfer(false)} + sendEvent={sendEvent} + incomingMessage={lastAgentMsg} + /> + )} + {showChat && connectionState === 'connected' && ( + setShowChat(false)} + sendEvent={sendEvent} + incomingMessage={lastAgentMsg} + /> )}
diff --git a/auth.ts b/auth.ts index bc067a1..ca03c24 100644 --- a/auth.ts +++ b/auth.ts @@ -42,7 +42,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ callbacks: { jwt({ token, user }) { if (user) { - token.id = user.id + token.id = user.id ?? '' token.role = (user as { role: string }).role } return token diff --git a/components/dashboard/machine-card.tsx b/components/dashboard/machine-card.tsx new file mode 100644 index 0000000..38b6349 --- /dev/null +++ b/components/dashboard/machine-card.tsx @@ -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 = { + 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(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 ( + + +
+
+ +
+
+ {editing ? ( + setName(e.target.value)} + className="h-7 text-sm font-semibold" + autoFocus + /> + ) : ( +

{machine.name}

+ )} +

{machine.hostname || 'Unknown host'}

+
+
+ + + + + + + setEditing(true)}> + + Edit + + setShowNotes(v => !v)}> + + {showNotes ? 'Hide notes' : 'Show notes'} + + + + + Delete + + + +
+ + + {/* Tags */} +
+ {tags.map(t => ( + + {t} + {editing && ( + + )} + + ))} + {editing && ( +
+ 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" + /> + +
+ )} +
+ + {/* Group */} + {editing ? ( +
+ + +
+ ) : currentGroup ? ( +

Group: {currentGroup.name}

+ ) : null} + + {/* Stats */} +
+
Status
+
+ + {machine.isOnline ? 'Online' : 'Offline'} +
+
OS
+
{machine.os || 'Unknown'} {machine.osVersion || ''}
+
Last seen
+
+ {machine.lastSeen ? formatDistanceToNow(new Date(machine.lastSeen), { addSuffix: true }) : 'Never'} +
+
Agent
+
{machine.agentVersion || 'N/A'}
+
+ + {/* Notes */} + {(showNotes || editing) && ( +
+ {editing ? ( +