20 lines
89 KiB
Text
20 lines
89 KiB
Text
|
|
{"parentUuid":null,"isSidechain":true,"promptId":"e176c23d-ff7d-491e-8d84-5434eb95c4b7","agentId":"afe6f1cc6744d8df1","type":"user","message":{"role":"user","content":"Read the following files from the dragonrelay Forgejo repository (owner: zgaetano, repo: dragonrelay) using the forgejo_get_file tool:\n1. src/App.tsx\n2. src/api.ts\n3. src/pages/Settings.tsx\n4. src/pages/Login.tsx\n5. src/components/HostCard.tsx\n6. src/components/SessionsTable.tsx\n7. src/components/StatusBar.tsx\n8. src/components/AddHostModal.tsx\n\nReturn ALL file contents in full with clear file path headers."},"uuid":"ca836009-da9c-4bf8-a16d-168747d4335b","timestamp":"2026-05-06T03:20:44.707Z","userType":"external","entrypoint":"sdk-ts","cwd":"/home/node","sessionId":"9b13371c-0b62-457e-a8bf-a331b2d5b744","version":"2.1.129","gitBranch":"HEAD"}
|
||
|
|
{"parentUuid":"ca836009-da9c-4bf8-a16d-168747d4335b","isSidechain":true,"agentId":"afe6f1cc6744d8df1","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01VdGcWZRLMCRjBEAjDdWTDf","type":"message","role":"assistant","content":[{"type":"text","text":"I'll retrieve all the specified files from the dragonrelay repository for you."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":555,"cache_read_input_tokens":94031,"cache_creation":{"ephemeral_5m_input_tokens":555,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CakbUUqiAFRJirHaSNuqJ","attributionAgent":"Explore","type":"assistant","uuid":"7e1f7189-ea0e-45e4-87d1-b36953d0843a","timestamp":"2026-05-06T03:20:46.852Z","userType":"external","entrypoint":"sdk-ts","cwd":"/home/node","sessionId":"9b13371c-0b62-457e-a8bf-a331b2d5b744","version":"2.1.129","gitBranch":"HEAD"}
|
||
|
|
{"parentUuid":"7e1f7189-ea0e-45e4-87d1-b36953d0843a","isSidechain":true,"agentId":"afe6f1cc6744d8df1","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01VdGcWZRLMCRjBEAjDdWTDf","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01WVvcb875TnqdCMWhC5owaY","name":"mcp__claude_ai_mcp-server__forgejo_forgejo_get_file","input":{"owner":"zgaetano","repo":"dragonrelay","path":"src/App.tsx"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":555,"cache_read_input_tokens":94031,"cache_creation":{"ephemeral_5m_input_tokens":555,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CakbUUqiAFRJirHaSNuqJ","attributionAgent":"Explore","type":"assistant","uuid":"2f99f66d-2d72-4557-9cdf-9c3d50fc9edb","timestamp":"2026-05-06T03:20:47.199Z","userType":"external","entrypoint":"sdk-ts","cwd":"/home/node","sessionId":"9b13371c-0b62-457e-a8bf-a331b2d5b744","version":"2.1.129","gitBranch":"HEAD"}
|
||
|
|
{"parentUuid":"2f99f66d-2d72-4557-9cdf-9c3d50fc9edb","isSidechain":true,"agentId":"afe6f1cc6744d8df1","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01VdGcWZRLMCRjBEAjDdWTDf","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01MyQzqWiyXZ9hZGcBcpNFtT","name":"mcp__claude_ai_mcp-server__forgejo_forgejo_get_file","input":{"owner":"zgaetano","repo":"dragonrelay","path":"src/api.ts"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":555,"cache_read_input_tokens":94031,"cache_creation":{"ephemeral_5m_input_tokens":555,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CakbUUqiAFRJirHaSNuqJ","attributionAgent":"Explore","type":"assistant","uuid":"b1a943e5-f178-45de-986c-586f79070fe2","timestamp":"2026-05-06T03:20:47.469Z","userType":"external","entrypoint":"sdk-ts","cwd":"/home/node","sessionId":"9b13371c-0b62-457e-a8bf-a331b2d5b744","version":"2.1.129","gitBranch":"HEAD"}
|
||
|
|
{"parentUuid":"b1a943e5-f178-45de-986c-586f79070fe2","isSidechain":true,"agentId":"afe6f1cc6744d8df1","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01VdGcWZRLMCRjBEAjDdWTDf","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01KjmdUdDyff5HyEKJkmcrtu","name":"mcp__claude_ai_mcp-server__forgejo_forgejo_get_file","input":{"owner":"zgaetano","repo":"dragonrelay","path":"src/pages/Settings.tsx"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":555,"cache_read_input_tokens":94031,"cache_creation":{"ephemeral_5m_input_tokens":555,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CakbUUqiAFRJirHaSNuqJ","attributionAgent":"Explore","type":"assistant","uuid":"24027799-e2a5-49e6-a4c5-82c9f31f99c6","timestamp":"2026-05-06T03:20:47.948Z","userType":"external","entrypoint":"sdk-ts","cwd":"/home/node","sessionId":"9b13371c-0b62-457e-a8bf-a331b2d5b744","version":"2.1.129","gitBranch":"HEAD"}
|
||
|
|
{"parentUuid":"24027799-e2a5-49e6-a4c5-82c9f31f99c6","isSidechain":true,"agentId":"afe6f1cc6744d8df1","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01VdGcWZRLMCRjBEAjDdWTDf","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01BtR2VPwoQqQRBk8XDjYasH","name":"mcp__claude_ai_mcp-server__forgejo_forgejo_get_file","input":{"owner":"zgaetano","repo":"dragonrelay","path":"src/pages/Login.tsx"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":555,"cache_read_input_tokens":94031,"cache_creation":{"ephemeral_5m_input_tokens":555,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CakbUUqiAFRJirHaSNuqJ","attributionAgent":"Explore","type":"assistant","uuid":"b6fa48b1-3568-4adc-9bb1-4156c1c4c2a3","timestamp":"2026-05-06T03:20:48.081Z","userType":"external","entrypoint":"sdk-ts","cwd":"/home/node","sessionId":"9b13371c-0b62-457e-a8bf-a331b2d5b744","version":"2.1.129","gitBranch":"HEAD"}
|
||
|
|
{"parentUuid":"b6fa48b1-3568-4adc-9bb1-4156c1c4c2a3","isSidechain":true,"agentId":"afe6f1cc6744d8df1","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01VdGcWZRLMCRjBEAjDdWTDf","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_019bLhmFtdTceA6fBf2xev3q","name":"mcp__claude_ai_mcp-server__forgejo_forgejo_get_file","input":{"owner":"zgaetano","repo":"dragonrelay","path":"src/components/HostCard.tsx"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":555,"cache_read_input_tokens":94031,"cache_creation":{"ephemeral_5m_input_tokens":555,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CakbUUqiAFRJirHaSNuqJ","attributionAgent":"Explore","type":"assistant","uuid":"c11e1d0a-fbbb-4bb3-a424-225b50731f33","timestamp":"2026-05-06T03:20:48.656Z","userType":"external","entrypoint":"sdk-ts","cwd":"/home/node","sessionId":"9b13371c-0b62-457e-a8bf-a331b2d5b744","version":"2.1.129","gitBranch":"HEAD"}
|
||
|
|
{"parentUuid":"c11e1d0a-fbbb-4bb3-a424-225b50731f33","isSidechain":true,"agentId":"afe6f1cc6744d8df1","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01VdGcWZRLMCRjBEAjDdWTDf","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01NByrQ1U1pymaKsrQtvnxaF","name":"mcp__claude_ai_mcp-server__forgejo_forgejo_get_file","input":{"owner":"zgaetano","repo":"dragonrelay","path":"src/components/SessionsTable.tsx"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":555,"cache_read_input_tokens":94031,"cache_creation":{"ephemeral_5m_input_tokens":555,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CakbUUqiAFRJirHaSNuqJ","attributionAgent":"Explore","type":"assistant","uuid":"9a50b68a-8438-42fe-9930-5ed202899580","timestamp":"2026-05-06T03:20:48.971Z","userType":"external","entrypoint":"sdk-ts","cwd":"/home/node","sessionId":"9b13371c-0b62-457e-a8bf-a331b2d5b744","version":"2.1.129","gitBranch":"HEAD"}
|
||
|
|
{"parentUuid":"9a50b68a-8438-42fe-9930-5ed202899580","isSidechain":true,"agentId":"afe6f1cc6744d8df1","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01VdGcWZRLMCRjBEAjDdWTDf","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Es9nUKzQRE2HSpDZtka4iW","name":"mcp__claude_ai_mcp-server__forgejo_forgejo_get_file","input":{"owner":"zgaetano","repo":"dragonrelay","path":"src/components/StatusBar.tsx"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":555,"cache_read_input_tokens":94031,"cache_creation":{"ephemeral_5m_input_tokens":555,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CakbUUqiAFRJirHaSNuqJ","attributionAgent":"Explore","type":"assistant","uuid":"5ffe070a-f74c-4cd0-b368-1f854d409ac9","timestamp":"2026-05-06T03:20:49.202Z","userType":"external","entrypoint":"sdk-ts","cwd":"/home/node","sessionId":"9b13371c-0b62-457e-a8bf-a331b2d5b744","version":"2.1.129","gitBranch":"HEAD"}
|
||
|
|
{"parentUuid":"5ffe070a-f74c-4cd0-b368-1f854d409ac9","isSidechain":true,"agentId":"afe6f1cc6744d8df1","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01VdGcWZRLMCRjBEAjDdWTDf","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01TAATVkydLFtpCkWiNngNJ5","name":"mcp__claude_ai_mcp-server__forgejo_forgejo_get_file","input":{"owner":"zgaetano","repo":"dragonrelay","path":"src/components/AddHostModal.tsx"},"caller":{"type":"direct"}}],"stop_reason":"tool_use","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":555,"cache_read_input_tokens":94031,"output_tokens":838,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":555},"inference_geo":"","iterations":[{"input_tokens":3,"output_tokens":838,"cache_read_input_tokens":94031,"cache_creation_input_tokens":555,"cache_creation":{"ephemeral_5m_input_tokens":555,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"}},"requestId":"req_011CakbUUqiAFRJirHaSNuqJ","attributionAgent":"Explore","type":"assistant","uuid":"95d37c71-93c5-406e-be56-33dbd4876728","timestamp":"2026-05-06T03:20:49.382Z","userType":"external","entrypoint":"sdk-ts","cwd":"/home/node","sessionId":"9b13371c-0b62-457e-a8bf-a331b2d5b744","version":"2.1.129","gitBranch":"HEAD"}
|
||
|
|
{"parentUuid":"2f99f66d-2d72-4557-9cdf-9c3d50fc9edb","isSidechain":true,"promptId":"e176c23d-ff7d-491e-8d84-5434eb95c4b7","agentId":"afe6f1cc6744d8df1","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01WVvcb875TnqdCMWhC5owaY","type":"tool_result","content":"{\"result\":\"File: src/App.tsx\\n\\nimport { useState } from \\\"react\\\";\\nimport { useStatus } from \\\"./hooks/useStatus\\\";\\nimport { StatusBar } from \\\"./components/StatusBar\\\";\\nimport { HostCard } from \\\"./components/HostCard\\\";\\nimport { AddHostModal } from \\\"./components/AddHostModal\\\";\\nimport { Login } from \\\"./pages/Login\\\";\\nimport { Settings } from \\\"./pages/Settings\\\";\\nimport { useAuth, RequireRole } from \\\"./context/AuthContext\\\";\\n\\nexport default function App() {\\n const { isAuthenticated, username, logout } = useAuth();\\n const { status, hosts, error, loading, refresh } = useStatus(3000);\\n const [showAddModal, setShowAddModal] = useState(false);\\n const [showSettings, setShowSettings] = useState(false);\\n\\n if (!isAuthenticated) return <Login />;\\n if (showSettings) return <Settings onClose={() => setShowSettings(false)} />;\\n\\n return (\\n <div className=\\\"flex flex-col h-screen bg-surface select-none\\\">\\n {/* Header */}\\n <div\\n data-tauri-drag-region\\n className=\\\"flex items-center justify-between px-4 py-3 bg-surface-raised\\\"\\n style={{ WebkitAppRegion: \\\"drag\\\" } as React.CSSProperties}\\n >\\n <div className=\\\"flex items-center gap-2\\\">\\n <span className=\\\"text-accent font-bold text-lg\\\">🐉 Dragon Flight Relay</span>\\n </div>\\n <div\\n className=\\\"flex items-center gap-3\\\"\\n style={{ WebkitAppRegion: \\\"no-drag\\\" } as React.CSSProperties}\\n >\\n <button\\n onClick={refresh}\\n className=\\\"text-gray-400 hover:text-white text-xs transition-colors\\\"\\n title=\\\"Refresh hosts\\\"\\n >\\n ↻ Refresh\\n </button>\\n <span className=\\\"text-gray-500 text-xs\\\">{username}</span>\\n <RequireRole roles={[\\\"admin\\\"]}>\\n <button\\n onClick={() => setShowSettings(true)}\\n className=\\\"text-gray-400 hover:text-white text-base transition-colors\\\"\\n title=\\\"Admin Settings\\\"\\n >\\n ⚙\\n </button>\\n </RequireRole>\\n <button\\n onClick={logout}\\n className=\\\"text-gray-400 hover:text-white text-xs transition-colors\\\"\\n >\\n Sign out\\n </button>\\n </div>\\n </div>\\n\\n <StatusBar status={status} error={error} loading={loading} />\\n\\n <main className=\\\"flex-1 overflow-y-auto p-4\\\">\\n {hosts.length === 0 && !loading ? (\\n <div className=\\\"flex flex-col items-center justify-center h-full gap-3 text-gray-500\\\">\\n <span className=\\\"text-5xl\\\">🔍</span>\\n <p className=\\\"text-sm\\\">No hosts found yet.</p>\\n <p className=\\\"text-xs text-center max-w-xs\\\">\\n Make sure Apollo/Artemis is running on your gaming PC and it's\\n reachable over Tailscale (or your local network).\\n </p>\\n <RequireRole roles={[\\\"admin\\\", \\\"user\\\"]}>\\n <button\\n onClick={() => setShowAddModal(true)}\\n className=\\\"mt-2 px-4 py-2 rounded-lg bg-accent text-sm font-medium\\\"\\n >\\n + Add host manually\\n </button>\\n </RequireRole>\\n </div>\\n ) : (\\n <div className=\\\"grid grid-cols-2 gap-3 sm:grid-cols-3\\\">\\n {hosts.map((h) => (\\n <HostCard key={h.name + h.ip} host={h} onRefresh={refresh} />\\n ))}\\n <RequireRole roles={[\\\"admin\\\", \\\"user\\\"]}>\\n <button\\n
|
||
|
|
{"parentUuid":"b1a943e5-f178-45de-986c-586f79070fe2","isSidechain":true,"promptId":"e176c23d-ff7d-491e-8d84-5434eb95c4b7","agentId":"afe6f1cc6744d8df1","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01MyQzqWiyXZ9hZGcBcpNFtT","type":"tool_result","content":"{\"result\":\"File: src/api.ts\\n\\n// api.ts — typed client for the Dragon Flight Relay REST API.\\nconst BASE = (import.meta.env.VITE_API_BASE as string | undefined) ?? \\\"\\\";\\n\\n// ── Types ─────────────────────────────────────────────────────────────\\n\\nexport interface AppStatus {\\n vpn_connected: boolean;\\n vpn_address: string;\\n moonlight_path: string;\\n}\\n\\nexport interface Host {\\n name: string;\\n ip: string;\\n port: number;\\n online: boolean;\\n seen_at: string;\\n}\\n\\nexport interface Session {\\n id: string;\\n host_name: string;\\n ip: string;\\n app: string;\\n pid: number;\\n started_at: string;\\n}\\n\\nexport interface UserRecord {\\n id: number;\\n username: string;\\n role: \\\"admin\\\" | \\\"user\\\" | \\\"viewer\\\";\\n created_at: string;\\n}\\n\\nexport interface AdminConfig {\\n ts_authkey: string; // masked on server\\n ts_hostname: string;\\n no_tailscale: boolean;\\n}\\n\\n// ── Token storage ──────────────────────────────────────────────────────\\n\\nexport function getToken(): string | null {\\n return localStorage.getItem(\\\"dragonrelay_token\\\");\\n}\\n\\nexport function setToken(token: string) {\\n localStorage.setItem(\\\"dragonrelay_token\\\", token);\\n}\\n\\nexport function clearToken() {\\n localStorage.removeItem(\\\"dragonrelay_token\\\");\\n}\\n\\n// ── Request helper ─────────────────────────────────────────────────────\\n\\nasync function request<T>(\\n method: string,\\n path: string,\\n body?: unknown\\n): Promise<T> {\\n const headers: Record<string, string> = {};\\n if (body) headers[\\\"Content-Type\\\"] = \\\"application/json\\\";\\n const token = getToken();\\n if (token) headers[\\\"Authorization\\\"] = `Bearer ${token}`;\\n\\n const res = await fetch(`${BASE}${path}`, {\\n method,\\n headers,\\n body: body ? JSON.stringify(body) : undefined,\\n });\\n\\n if (res.status === 401) {\\n clearToken();\\n window.location.reload();\\n throw new Error(\\\"Session expired\\\");\\n }\\n\\n if (!res.ok) {\\n const err = await res.json().catch(() => ({ error: res.statusText }));\\n throw new Error(err.error ?? res.statusText);\\n }\\n return res.json();\\n}\\n\\n// ── API ────────────────────────────────────────────────────────────────\\n\\nexport const api = {\\n // Auth\\n login: (username: string, password: string) =>\\n request<{ token: string; username: string; role: string }>(\\n \\\"POST\\\", \\\"/api/auth/login\\\", { username, password }\\n ),\\n logout: () => request<{ ok: boolean }>(\\\"POST\\\", \\\"/api/auth/logout\\\"),\\n\\n // Core\\n getStatus: () => request<AppStatus>(\\\"GET\\\", \\\"/api/status\\\"),\\n getHosts: () => request<Host[]>(\\\"GET\\\", \\\"/api/hosts\\\"),\\n\\n addManualHost: (name: string, ip: string, port = 47984) =>\\n request<{ ok: boolean }>(\\\"POST\\\", \\\"/api/hosts/manual\\\", { name, ip, port }),\\n\\n removeHost: (ip: string) =>\\n request<{ ok: boolean }>(\\\"DELETE\\\", `/api/hosts/${ip}`),\\n\\n connect: (ip: string, app = \\\"Desktop\\\") =>\\n request<{ ok: boolean }>(\\\"POST\\\", \\\"/api/connect\\\", { ip, app }),\\n\\n pair: (ip: string) =>\\n request<{ ok: boolean }>(\\\"POST\\\", \\\"/api/pair\\\", { ip }),\\n\\n // Sessions (all authenticated roles)\\n getSe
|
||
|
|
{"parentUuid":"24027799-e2a5-49e6-a4c5-82c9f31f99c6","isSidechain":true,"promptId":"e176c23d-ff7d-491e-8d84-5434eb95c4b7","agentId":"afe6f1cc6744d8df1","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01KjmdUdDyff5HyEKJkmcrtu","type":"tool_result","content":"{\"result\":\"File: src/pages/Settings.tsx\\n\\nimport { useState, useEffect, FormEvent } from \\\"react\\\";\\nimport { api, AdminConfig, UserRecord, Host } from \\\"../api\\\";\\nimport { useAuth } from \\\"../context/AuthContext\\\";\\nimport { SessionsTable } from \\\"../components/SessionsTable\\\";\\nimport { AddHostModal } from \\\"../components/AddHostModal\\\";\\n\\n// ── Section A: VPN Config ───────────────────────────────────────────────\\n\\nfunction VpnSection() {\\n const [cfg, setCfg] = useState<AdminConfig | null>(null);\\n const [tsKey, setTsKey] = useState(\\\"\\\");\\n const [hostname, setHostname] = useState(\\\"\\\");\\n const [noTs, setNoTs] = useState(false);\\n const [saved, setSaved] = useState(false);\\n\\n useEffect(() => {\\n api.getAdminConfig().then((c) => {\\n setCfg(c);\\n setTsKey(c.ts_authkey);\\n setHostname(c.ts_hostname);\\n setNoTs(c.no_tailscale);\\n });\\n }, []);\\n\\n async function handleSave(e: FormEvent) {\\n e.preventDefault();\\n await api.updateAdminConfig({ ts_authkey: tsKey, ts_hostname: hostname, no_tailscale: noTs });\\n setSaved(true);\\n setTimeout(() => setSaved(false), 3000);\\n }\\n\\n if (!cfg) return <p className=\\\"text-gray-500 text-sm\\\">Loading…</p>;\\n\\n return (\\n <form onSubmit={handleSave} className=\\\"flex flex-col gap-4 max-w-md\\\">\\n <div>\\n <label className=\\\"text-xs text-gray-400 uppercase tracking-wide\\\">Auth Key</label>\\n <input\\n type=\\\"password\\\"\\n value={tsKey}\\n onChange={(e) => setTsKey(e.target.value)}\\n placeholder=\\\"tskey-auth-…\\\"\\n className=\\\"mt-1 w-full px-3 py-2 rounded-lg bg-surface-elevated text-white border border-surface-elevated focus:outline-none focus:border-accent text-sm\\\"\\n />\\n </div>\\n <div>\\n <label className=\\\"text-xs text-gray-400 uppercase tracking-wide\\\">Hostname</label>\\n <input\\n type=\\\"text\\\"\\n value={hostname}\\n onChange={(e) => setHostname(e.target.value)}\\n className=\\\"mt-1 w-full px-3 py-2 rounded-lg bg-surface-elevated text-white border border-surface-elevated focus:outline-none focus:border-accent text-sm\\\"\\n />\\n </div>\\n <label className=\\\"flex items-center gap-3 cursor-pointer\\\">\\n <input type=\\\"checkbox\\\" checked={noTs} onChange={(e) => setNoTs(e.target.checked)} className=\\\"w-4 h-4 accent-accent\\\" />\\n <span className=\\\"text-sm text-gray-300\\\">LAN-only mode (disable Tailscale)</span>\\n </label>\\n <p className=\\\"text-yellow-500/80 text-xs\\\">⚠ Changes require a container restart to take effect.</p>\\n <button type=\\\"submit\\\" className=\\\"self-start px-4 py-2 rounded-lg bg-accent hover:bg-accent-hover text-sm font-medium transition-colors\\\">\\n {saved ? \\\"Saved ✓\\\" : \\\"Save\\\"}\\n </button>\\n </form>\\n );\\n}\\n\\n// ── Section B: Host Management ─────────────────────────────────────────\\n\\nfunction HostsSection() {\\n const [hosts, setHosts] = useState<Host[]>([]);\\n const [showAdd, setShowAdd] = useState(false);\\n\\n function refresh() {\\n api.getHosts().then(setHosts).catch(() => {});\\n }\\n\\n useEffect(() => { refresh(); }, []);\\n\\n async function handleRemove(ip: string) {\\n await api.removeHost(ip);\\n refresh();\\n }\\n\\n return (\\n <div>\\n <div className=\\\"flex justify-between items-center mb-3\\\">\\n <span className=\\\"text-sm text-gray-400\\\">{hosts.l
|
||
|
|
{"parentUuid":"b6fa48b1-3568-4adc-9bb1-4156c1c4c2a3","isSidechain":true,"promptId":"e176c23d-ff7d-491e-8d84-5434eb95c4b7","agentId":"afe6f1cc6744d8df1","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01BtR2VPwoQqQRBk8XDjYasH","type":"tool_result","content":"{\"result\":\"File: src/pages/Login.tsx\\n\\nimport { useState, FormEvent } from \\\"react\\\";\\nimport { useAuth } from \\\"../context/AuthContext\\\";\\n\\nexport function Login() {\\n const { login } = useAuth();\\n const [username, setUsername] = useState(\\\"\\\");\\n const [password, setPassword] = useState(\\\"\\\");\\n const [error, setError] = useState<string | null>(null);\\n const [loading, setLoading] = useState(false);\\n\\n async function handleSubmit(e: FormEvent) {\\n e.preventDefault();\\n setError(null);\\n setLoading(true);\\n try {\\n await login(username, password);\\n } catch (err: unknown) {\\n setError(err instanceof Error ? err.message : \\\"Login failed\\\");\\n } finally {\\n setLoading(false);\\n }\\n }\\n\\n return (\\n <div className=\\\"flex items-center justify-center h-screen bg-surface\\\">\\n <div className=\\\"w-full max-w-sm p-8 rounded-2xl bg-surface-raised border border-surface-elevated shadow-xl\\\">\\n <div className=\\\"text-center mb-8\\\">\\n <span className=\\\"text-5xl\\\">🐉</span>\\n <h1 className=\\\"mt-3 text-xl font-bold text-white\\\">Dragon Flight Relay</h1>\\n <p className=\\\"text-sm text-gray-400 mt-1\\\">Sign in to continue</p>\\n </div>\\n\\n <form onSubmit={handleSubmit} className=\\\"flex flex-col gap-4\\\">\\n <input\\n type=\\\"text\\\"\\n placeholder=\\\"Username\\\"\\n value={username}\\n onChange={(e) => setUsername(e.target.value)}\\n required\\n autoFocus\\n className=\\\"px-4 py-2.5 rounded-lg bg-surface-elevated text-white placeholder-gray-500 border border-surface-elevated focus:outline-none focus:border-accent\\\"\\n />\\n <input\\n type=\\\"password\\\"\\n placeholder=\\\"Password\\\"\\n value={password}\\n onChange={(e) => setPassword(e.target.value)}\\n required\\n className=\\\"px-4 py-2.5 rounded-lg bg-surface-elevated text-white placeholder-gray-500 border border-surface-elevated focus:outline-none focus:border-accent\\\"\\n />\\n\\n {error && (\\n <p className=\\\"text-red-400 text-sm text-center\\\">{error}</p>\\n )}\\n\\n <button\\n type=\\\"submit\\\"\\n disabled={loading}\\n className=\\\"py-2.5 rounded-lg bg-accent hover:bg-accent-hover font-medium text-white disabled:opacity-50 transition-colors\\\"\\n >\\n {loading ? \\\"Signing in…\\\" : \\\"Sign in\\\"}\\n </button>\\n </form>\\n </div>\\n </div>\\n );\\n}\\n\"}"}]},"uuid":"d322d922-4df9-4b4c-8116-d5f513c969f1","timestamp":"2026-05-06T03:20:56.467Z","sourceToolAssistantUUID":"b6fa48b1-3568-4adc-9bb1-4156c1c4c2a3","userType":"external","entrypoint":"sdk-ts","cwd":"/home/node","sessionId":"9b13371c-0b62-457e-a8bf-a331b2d5b744","version":"2.1.129","gitBranch":"HEAD"}
|
||
|
|
{"parentUuid":"c11e1d0a-fbbb-4bb3-a424-225b50731f33","isSidechain":true,"promptId":"e176c23d-ff7d-491e-8d84-5434eb95c4b7","agentId":"afe6f1cc6744d8df1","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_019bLhmFtdTceA6fBf2xev3q","type":"tool_result","content":"{\"result\":\"File: src/components/HostCard.tsx\\n\\nimport { useState } from \\\"react\\\";\\nimport { Host, api } from \\\"../api\\\";\\nimport { RequireRole } from \\\"../context/AuthContext\\\";\\n\\ninterface Props {\\n host: Host;\\n onRefresh: () => void;\\n}\\n\\nexport function HostCard({ host, onRefresh }: Props) {\\n const [connecting, setConnecting] = useState(false);\\n const [pairing, setPairing] = useState(false);\\n const [flash, setFlash] = useState<string | null>(null);\\n\\n function showFlash(msg: string) {\\n setFlash(msg);\\n setTimeout(() => setFlash(null), 3000);\\n }\\n\\n async function handleConnect() {\\n setConnecting(true);\\n try {\\n await api.connect(host.ip);\\n showFlash(\\\"Launching Moonlight…\\\");\\n } catch (e: unknown) {\\n showFlash(e instanceof Error ? e.message : \\\"Failed to connect\\\");\\n } finally {\\n setConnecting(false);\\n }\\n }\\n\\n async function handlePair() {\\n setPairing(true);\\n try {\\n await api.pair(host.ip);\\n showFlash(\\\"Pairing dialog opened…\\\");\\n onRefresh();\\n } catch (e: unknown) {\\n showFlash(e instanceof Error ? e.message : \\\"Pairing failed\\\");\\n } finally {\\n setPairing(false);\\n }\\n }\\n\\n const onlineDot = host.online ? \\\"bg-green-400\\\" : \\\"bg-gray-500\\\";\\n\\n return (\\n <div\\n className={`\\n relative flex flex-col gap-3 p-5 rounded-xl border\\n bg-surface-raised\\n ${host.online\\n ? \\\"border-surface-elevated hover:border-accent/60 cursor-pointer group\\\"\\n : \\\"border-surface-elevated opacity-60\\\"\\n }\\n transition-all duration-200\\n `}\\n >\\n {/* Online indicator */}\\n <span className={`absolute top-4 right-4 w-2.5 h-2.5 rounded-full ${onlineDot}`} />\\n\\n {/* Host icon placeholder */}\\n <div className=\\\"w-12 h-12 rounded-lg bg-surface-elevated flex items-center justify-center text-2xl\\\">\\n 🖥\\n </div>\\n\\n <div>\\n <h3 className=\\\"font-semibold text-white truncate\\\">{host.name}</h3>\\n <p className=\\\"text-xs text-gray-400 mt-0.5\\\">\\n {host.ip}:{host.port}\\n </p>\\n </div>\\n\\n {/* Flash message */}\\n {flash && (\\n <p className=\\\"text-xs text-accent animate-pulse\\\">{flash}</p>\\n )}\\n\\n {/* Actions */}\\n <div className=\\\"flex gap-2 mt-auto\\\">\\n <RequireRole roles={[\\\"admin\\\", \\\"user\\\"]} fallback={\\n <span className=\\\"text-xs text-gray-500 italic\\\">View only</span>\\n }>\\n <button\\n onClick={handleConnect}\\n disabled={!host.online || connecting}\\n className=\\\"flex-1 py-1.5 px-3 rounded-lg text-sm font-medium bg-accent hover:bg-accent-hover disabled:opacity-40 disabled:cursor-not-allowed transition-colors duration-150\\\"\\n >\\n {connecting ? \\\"Launching…\\\" : \\\"Connect\\\"}\\n </button>\\n <button\\n onClick={handlePair}\\n disabled={!host.online || pairing}\\n className=\\\"py-1.5 px-3 rounded-lg text-sm font-medium bg-surface-elevated hover:bg-surface-elevated/80 disabled:opacity-40 disabled:cursor-not-allowed transition-colors duration-150\\\"\\n >\\n {pairing ? \\\"…\\\" : \\\"Pair\\\"}\\n </button>\\n </RequireRole>\\n </div>\\n </div>\\n );\\n}\\n\"}"}]},"uuid":"6b66d1ca-cc9b-41b4-8b1e-48c459d13bae","timestamp":"2026-05-06T03:20:58.807Z","sourceToolAssistantUUID":"c11e1d0a-fbbb-4bb3-a424-225b50731f33","userType":"external","entrypoint":"sdk-ts","cwd":"/home/node","sessionId":"9b13371c-0b62-457e-a8bf-a331b2d5b744
|
||
|
|
{"parentUuid":"9a50b68a-8438-42fe-9930-5ed202899580","isSidechain":true,"promptId":"e176c23d-ff7d-491e-8d84-5434eb95c4b7","agentId":"afe6f1cc6744d8df1","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01NByrQ1U1pymaKsrQtvnxaF","type":"tool_result","content":"{\"result\":\"File: src/components/SessionsTable.tsx\\n\\nimport { useState, useEffect } from \\\"react\\\";\\nimport { Session, api } from \\\"../api\\\";\\nimport { RequireRole } from \\\"../context/AuthContext\\\";\\n\\nfunction useDuration(startedAt: string): string {\\n const [now, setNow] = useState(Date.now());\\n useEffect(() => {\\n const id = setInterval(() => setNow(Date.now()), 1000);\\n return () => clearInterval(id);\\n }, []);\\n const secs = Math.floor((now - new Date(startedAt).getTime()) / 1000);\\n const h = Math.floor(secs / 3600);\\n const m = Math.floor((secs % 3600) / 60);\\n const s = secs % 60;\\n if (h > 0) return `${h}h ${m}m ${s}s`;\\n if (m > 0) return `${m}m ${s}s`;\\n return `${s}s`;\\n}\\n\\nfunction SessionRow({ session, onDisconnect }: { session: Session; onDisconnect: () => void }) {\\n const duration = useDuration(session.started_at);\\n const [loading, setLoading] = useState(false);\\n\\n async function handleDisconnect() {\\n setLoading(true);\\n try {\\n await api.disconnectSession(session.id);\\n onDisconnect();\\n } finally {\\n setLoading(false);\\n }\\n }\\n\\n return (\\n <tr className=\\\"border-t border-surface-elevated\\\">\\n <td className=\\\"py-2 pr-4 text-white\\\">{session.host_name || session.ip}</td>\\n <td className=\\\"py-2 pr-4 text-gray-400 text-sm font-mono\\\">{session.ip}</td>\\n <td className=\\\"py-2 pr-4 text-gray-300\\\">{session.app}</td>\\n <td className=\\\"py-2 pr-4 text-gray-400 text-sm tabular-nums\\\">{duration}</td>\\n <td className=\\\"py-2\\\">\\n <RequireRole roles={[\\\"admin\\\"]}>\\n <button\\n onClick={handleDisconnect}\\n disabled={loading}\\n className=\\\"px-3 py-1 rounded-lg text-xs bg-red-900/50 text-red-300 hover:bg-red-800/60 disabled:opacity-40 transition-colors\\\"\\n >\\n {loading ? \\\"…\\\" : \\\"Disconnect\\\"}\\n </button>\\n </RequireRole>\\n </td>\\n </tr>\\n );\\n}\\n\\nexport function SessionsTable() {\\n const [sessions, setSessions] = useState<Session[]>([]);\\n\\n function refresh() {\\n api.getSessions().then(setSessions).catch(() => {});\\n }\\n\\n useEffect(() => {\\n refresh();\\n const id = setInterval(refresh, 5000);\\n return () => clearInterval(id);\\n }, []);\\n\\n if (sessions.length === 0) {\\n return <p className=\\\"text-gray-500 text-sm py-4\\\">No active sessions</p>;\\n }\\n\\n return (\\n <table className=\\\"w-full text-sm\\\">\\n <thead>\\n <tr className=\\\"text-left text-gray-400 text-xs uppercase tracking-wide\\\">\\n <th className=\\\"pb-2 pr-4\\\">Host</th>\\n <th className=\\\"pb-2 pr-4\\\">IP</th>\\n <th className=\\\"pb-2 pr-4\\\">App</th>\\n <th className=\\\"pb-2 pr-4\\\">Duration</th>\\n <th className=\\\"pb-2\\\">Actions</th>\\n </tr>\\n </thead>\\n <tbody>\\n {sessions.map((s) => (\\n <SessionRow key={s.id} session={s} onDisconnect={refresh} />\\n ))}\\n </tbody>\\n </table>\\n );\\n}\\n\"}"}]},"uuid":"c632744e-fee0-4b54-b78c-9ec91a80fd8d","timestamp":"2026-05-06T03:21:01.144Z","sourceToolAssistantUUID":"9a50b68a-8438-42fe-9930-5ed202899580","userType":"external","entrypoint":"sdk-ts","cwd":"/home/node","sessionId":"9b13371c-0b62-457e-a8bf-a331b2d5b744","version":"2.1.129","gitBranch":"HEAD"}
|
||
|
|
{"parentUuid":"5ffe070a-f74c-4cd0-b368-1f854d409ac9","isSidechain":true,"promptId":"e176c23d-ff7d-491e-8d84-5434eb95c4b7","agentId":"afe6f1cc6744d8df1","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Es9nUKzQRE2HSpDZtka4iW","type":"tool_result","content":"{\"result\":\"File: src/components/StatusBar.tsx\\n\\nimport { AppStatus } from \\\"../api\\\";\\n\\ninterface Props {\\n status: AppStatus | null;\\n error: string | null;\\n loading: boolean;\\n}\\n\\nexport function StatusBar({ status, error, loading }: Props) {\\n if (loading) {\\n return (\\n <div className=\\\"flex items-center gap-2 px-4 py-2 bg-surface-raised border-b border-surface-elevated text-sm text-gray-400\\\">\\n <span className=\\\"w-2 h-2 rounded-full bg-gray-500 animate-pulse\\\" />\\n Connecting to backend…\\n </div>\\n );\\n }\\n\\n if (error) {\\n return (\\n <div className=\\\"flex items-center gap-2 px-4 py-2 bg-surface-raised border-b border-red-900 text-sm text-red-400\\\">\\n <span className=\\\"w-2 h-2 rounded-full bg-red-500\\\" />\\n Backend offline — {error}\\n </div>\\n );\\n }\\n\\n const vpnColor = status?.vpn_connected ? \\\"bg-green-400\\\" : \\\"bg-yellow-400\\\";\\n const vpnLabel = status?.vpn_connected\\n ? `VPN · ${status.vpn_address}`\\n : \\\"VPN · not connected\\\";\\n\\n return (\\n <div className=\\\"flex items-center gap-4 px-4 py-2 bg-surface-raised border-b border-surface-elevated text-sm\\\">\\n <span className=\\\"flex items-center gap-1.5\\\">\\n <span className={`w-2 h-2 rounded-full ${vpnColor}`} />\\n <span className=\\\"text-gray-300\\\">{vpnLabel}</span>\\n </span>\\n {status?.moonlight_path ? (\\n <span className=\\\"text-gray-500 truncate text-xs\\\">\\n 🎮 {status.moonlight_path}\\n </span>\\n ) : (\\n <span className=\\\"text-yellow-500 text-xs\\\">\\n ⚠ Moonlight not found — install it and restart\\n </span>\\n )}\\n </div>\\n );\\n}\\n\"}"}]},"uuid":"fd281633-1511-4d5f-8580-cc4eaf61dfb1","timestamp":"2026-05-06T03:21:03.484Z","sourceToolAssistantUUID":"5ffe070a-f74c-4cd0-b368-1f854d409ac9","userType":"external","entrypoint":"sdk-ts","cwd":"/home/node","sessionId":"9b13371c-0b62-457e-a8bf-a331b2d5b744","version":"2.1.129","gitBranch":"HEAD"}
|
||
|
|
{"parentUuid":"95d37c71-93c5-406e-be56-33dbd4876728","isSidechain":true,"promptId":"e176c23d-ff7d-491e-8d84-5434eb95c4b7","agentId":"afe6f1cc6744d8df1","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01TAATVkydLFtpCkWiNngNJ5","type":"tool_result","content":"{\"result\":\"File: src/components/AddHostModal.tsx\\n\\nimport { useState } from \\\"react\\\";\\nimport { api } from \\\"../api\\\";\\n\\ninterface Props {\\n onClose: () => void;\\n onAdded: () => void;\\n}\\n\\nexport function AddHostModal({ onClose, onAdded }: Props) {\\n const [name, setName] = useState(\\\"\\\");\\n const [ip, setIp] = useState(\\\"\\\");\\n const [port, setPort] = useState(\\\"47984\\\");\\n const [error, setError] = useState<string | null>(null);\\n const [saving, setSaving] = useState(false);\\n\\n async function handleSubmit(e: React.FormEvent) {\\n e.preventDefault();\\n setSaving(true);\\n setError(null);\\n try {\\n await api.addManualHost(name, ip, parseInt(port, 10) || 47984);\\n onAdded();\\n onClose();\\n } catch (err: unknown) {\\n setError(err instanceof Error ? err.message : \\\"Failed to add host\\\");\\n } finally {\\n setSaving(false);\\n }\\n }\\n\\n return (\\n <div className=\\\"fixed inset-0 bg-black/60 flex items-center justify-center z-50\\\" onClick={onClose}>\\n <div\\n className=\\\"bg-surface-raised border border-surface-elevated rounded-2xl p-6 w-80 shadow-2xl\\\"\\n onClick={(e) => e.stopPropagation()}\\n >\\n <h2 className=\\\"text-lg font-semibold mb-4\\\">Add Host Manually</h2>\\n <form onSubmit={handleSubmit} className=\\\"flex flex-col gap-3\\\">\\n <input\\n className=\\\"bg-surface-elevated rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-accent\\\"\\n placeholder=\\\"Display name\\\"\\n value={name}\\n onChange={(e) => setName(e.target.value)}\\n required\\n />\\n <input\\n className=\\\"bg-surface-elevated rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-accent\\\"\\n placeholder=\\\"IP or Tailscale hostname\\\"\\n value={ip}\\n onChange={(e) => setIp(e.target.value)}\\n required\\n />\\n <input\\n className=\\\"bg-surface-elevated rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-accent\\\"\\n placeholder=\\\"Port (default 47984)\\\"\\n value={port}\\n onChange={(e) => setPort(e.target.value)}\\n type=\\\"number\\\"\\n />\\n {error && <p className=\\\"text-xs text-red-400\\\">{error}</p>}\\n <div className=\\\"flex gap-2 mt-2\\\">\\n <button\\n type=\\\"button\\\"\\n onClick={onClose}\\n className=\\\"flex-1 py-2 rounded-lg bg-surface-elevated text-sm\\\"\\n >\\n Cancel\\n </button>\\n <button\\n type=\\\"submit\\\"\\n disabled={saving}\\n className=\\\"flex-1 py-2 rounded-lg bg-accent text-sm font-medium disabled:opacity-50\\\"\\n >\\n {saving ? \\\"Adding…\\\" : \\\"Add\\\"}\\n </button>\\n </div>\\n </form>\\n </div>\\n </div>\\n );\\n}\\n\"}"}]},"uuid":"55ac6d8e-4876-408c-8b63-40ba7b4296a1","timestamp":"2026-05-06T03:21:05.810Z","sourceToolAssistantUUID":"95d37c71-93c5-406e-be56-33dbd4876728","userType":"external","entrypoint":"sdk-ts","cwd":"/home/node","sessionId":"9b13371c-0b62-457e-a8bf-a331b2d5b744","version":"2.1.129","gitBranch":"HEAD"}
|
||
|
|
{"parentUuid":"55ac6d8e-4876-408c-8b63-40ba7b4296a1","isSidechain":true,"agentId":"afe6f1cc6744d8df1","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01G8HKjJ78ArEfqEiE7DCvL9","type":"message","role":"assistant","content":[{"type":"text","text":"Here are all the requested files from the dragonrelay repository with their full contents:\n\n## src/App.tsx\n\n```typescript\nimport { useState } from \"react\";\nimport { useStatus } from \"./hooks/useStatus\";\nimport { StatusBar } from \"./components/StatusBar\";\nimport { HostCard } from \"./components/HostCard\";\nimport { AddHostModal } from \"./components/AddHostModal\";\nimport { Login } from \"./pages/Login\";\nimport { Settings } from \"./pages/Settings\";\nimport { useAuth, RequireRole } from \"./context/AuthContext\";\n\nexport default function App() {\n const { isAuthenticated, username, logout } = useAuth();\n const { status, hosts, error, loading, refresh } = useStatus(3000);\n const [showAddModal, setShowAddModal] = useState(false);\n const [showSettings, setShowSettings] = useState(false);\n\n if (!isAuthenticated) return <Login />;\n if (showSettings) return <Settings onClose={() => setShowSettings(false)} />;\n\n return (\n <div className=\"flex flex-col h-screen bg-surface select-none\">\n {/* Header */}\n <div\n data-tauri-drag-region\n className=\"flex items-center justify-between px-4 py-3 bg-surface-raised\"\n style={{ WebkitAppRegion: \"drag\" } as React.CSSProperties}\n >\n <div className=\"flex items-center gap-2\">\n <span className=\"text-accent font-bold text-lg\">🐉 Dragon Flight Relay</span>\n </div>\n <div\n className=\"flex items-center gap-3\"\n style={{ WebkitAppRegion: \"no-drag\" } as React.CSSProperties}\n >\n <button\n onClick={refresh}\n className=\"text-gray-400 hover:text-white text-xs transition-colors\"\n title=\"Refresh hosts\"\n >\n ↻ Refresh\n </button>\n <span className=\"text-gray-500 text-xs\">{username}</span>\n <RequireRole roles={[\"admin\"]}>\n <button\n onClick={() => setShowSettings(true)}\n className=\"text-gray-400 hover:text-white text-base transition-colors\"\n title=\"Admin Settings\"\n >\n ⚙\n </button>\n </RequireRole>\n <button\n onClick={logout}\n className=\"text-gray-400 hover:text-white text-xs transition-colors\"\n >\n Sign out\n </button>\n </div>\n </div>\n\n <StatusBar status={status} error={error} loading={loading} />\n\n <main className=\"flex-1 overflow-y-auto p-4\">\n {hosts.length === 0 && !loading ? (\n <div className=\"flex flex-col items-center justify-center h-full gap-3 text-gray-500\">\n <span className=\"text-5xl\">🔍</span>\n <p className=\"text-sm\">No hosts found yet.</p>\n <p className=\"text-xs text-center max-w-xs\">\n Make sure Apollo/Artemis is running on your gaming PC and it's\n reachable over Tailscale (or your local network).\n </p>\n <RequireRole roles={[\"admin\", \"user\"]}>\n <button\n onClick={() => setShowAddModal(true)}\n className=\"mt-2 px-4 py-2 rounded-lg bg-accent text-sm font-medium\"\n >\n + Add host manually\n </button>\n </RequireRole>\n </div>\n ) : (\n <div className=\"grid grid-cols-2 gap-3 sm:grid-cols-3\">\n {hosts.map((h) => (\n <HostCard key={h.name + h.ip} host={h} onRefresh={refresh} />\n ))}\n <RequireRole roles={[\"admin\", \"user\"]}>\n <button\n onClick={() => setShowAddModal(true)}\n className=\"flex flex-col items-center justify-center gap-2 p-5 rounded-xl border border-dashed
|