19 lines
89 KiB
JSON
19 lines
89 KiB
JSON
{"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 onClick={() => setShowAddModal(true)}\\n className=\\\"flex flex-col items-center justify-center gap-2 p-5 rounded-xl border border-dashed border-surface-elevated text-gray-500 hover:text-gray-300 hover:border-gray-500 transition-colors duration-150\\\"\\n >\\n <span className=\\\"text-2xl\\\">+</span>\\n <span className=\\\"text-xs\\\">Add host</span>\\n </button>\\n </RequireRole>\\n </div>\\n )}\\n </main>\\n\\n {showAddModal && (\\n <AddHostModal\\n onClose={() => setShowAddModal(false)}\\n onAdded={refresh}\\n />\\n )}\\n </div>\\n );\\n}\\n\"}"}]},"uuid":"63d57e57-f156-4439-88b6-53c56686c984","timestamp":"2026-05-06T03:20:49.488Z","sourceToolAssistantUUID":"2f99f66d-2d72-4557-9cdf-9c3d50fc9edb","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,"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 getSessions: () => request<Session[]>(\\\"GET\\\", \\\"/api/admin/sessions\\\"),\\n\\n // Admin\\n getAdminConfig: () => request<AdminConfig>(\\\"GET\\\", \\\"/api/admin/config\\\"),\\n updateAdminConfig: (patch: Partial<AdminConfig>) =>\\n request<{ ok: boolean; restart_required: boolean }>(\\\"PUT\\\", \\\"/api/admin/config\\\", patch),\\n disconnectSession: (id: string) =>\\n request<{ ok: boolean }>(\\\"POST\\\", `/api/admin/sessions/${id}/disconnect`),\\n\\n // User management\\n listUsers: () => request<UserRecord[]>(\\\"GET\\\", \\\"/api/admin/users\\\"),\\n createUser: (username: string, password: string, role: string) =>\\n request<{ ok: boolean }>(\\\"POST\\\", \\\"/api/admin/users\\\", { username, password, role }),\\n updateUser: (id: number, patch: { role?: string; password?: string }) =>\\n request<{ ok: boolean }>(\\\"PUT\\\", `/api/admin/users/${id}`, patch),\\n deleteUser: (id: number) =>\\n request<{ ok: boolean }>(\\\"DELETE\\\", `/api/admin/users/${id}`),\\n};\\n\"}"}]},"uuid":"1e44b0e6-ec4d-4231-85bb-689033406418","timestamp":"2026-05-06T03:20:51.809Z","sourceToolAssistantUUID":"b1a943e5-f178-45de-986c-586f79070fe2","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,"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.length} host{hosts.length !== 1 ? \\\"s\\\" : \\\"\\\"}</span>\\n <button onClick={() => setShowAdd(true)} className=\\\"px-3 py-1.5 rounded-lg bg-accent hover:bg-accent-hover text-xs font-medium transition-colors\\\">\\n + Add Host\\n </button>\\n </div>\\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\\\">Name</th>\\n <th className=\\\"pb-2 pr-4\\\">IP</th>\\n <th className=\\\"pb-2 pr-4\\\">Port</th>\\n <th className=\\\"pb-2 pr-4\\\">Status</th>\\n <th className=\\\"pb-2\\\">Actions</th>\\n </tr>\\n </thead>\\n <tbody>\\n {hosts.map((h) => (\\n <tr key={h.ip} className=\\\"border-t border-surface-elevated\\\">\\n <td className=\\\"py-2 pr-4 text-white\\\">{h.name}</td>\\n <td className=\\\"py-2 pr-4 text-gray-400 font-mono text-xs\\\">{h.ip}</td>\\n <td className=\\\"py-2 pr-4 text-gray-400 text-xs\\\">{h.port}</td>\\n <td className=\\\"py-2 pr-4\\\">\\n <span className={`text-xs px-2 py-0.5 rounded-full ${h.online ? \\\"bg-green-900/50 text-green-300\\\" : \\\"bg-gray-700 text-gray-400\\\"}`}>\\n {h.online ? \\\"Online\\\" : \\\"Offline\\\"}\\n </span>\\n </td>\\n <td className=\\\"py-2\\\">\\n <button onClick={() => handleRemove(h.ip)} className=\\\"px-3 py-1 rounded-lg text-xs bg-red-900/50 text-red-300 hover:bg-red-800/60 transition-colors\\\">\\n Remove\\n </button>\\n </td>\\n </tr>\\n ))}\\n </tbody>\\n </table>\\n {showAdd && <AddHostModal onClose={() => setShowAdd(false)} onAdded={refresh} />}\\n </div>\\n );\\n}\\n\\n// ── Section D: Users ───────────────────────────────────────────────────\\n\\nfunction UsersSection() {\\n const { username: selfUsername } = useAuth();\\n const [users, setUsers] = useState<UserRecord[]>([]);\\n const [newUser, setNewUser] = useState({ username: \\\"\\\", password: \\\"\\\", role: \\\"user\\\" });\\n const [creating, setCreating] = useState(false);\\n const [resetPw, setResetPw] = useState<{ id: number; pw: string } | null>(null);\\n\\n function refresh() { api.listUsers().then(setUsers).catch(() => {}); }\\n useEffect(() => { refresh(); }, []);\\n\\n async function handleCreate(e: FormEvent) {\\n e.preventDefault();\\n setCreating(true);\\n try {\\n await api.createUser(newUser.username, newUser.password, newUser.role);\\n setNewUser({ username: \\\"\\\", password: \\\"\\\", role: \\\"user\\\" });\\n refresh();\\n } finally {\\n setCreating(false);\\n }\\n }\\n\\n async function handleRoleChange(id: number, role: string) {\\n await api.updateUser(id, { role });\\n refresh();\\n }\\n\\n async function handleResetPassword(id: number, pw: string) {\\n await api.updateUser(id, { password: pw });\\n setResetPw(null);\\n }\\n\\n async function handleDelete(id: number) {\\n await api.deleteUser(id);\\n refresh();\\n }\\n\\n return (\\n <div className=\\\"flex flex-col gap-6\\\">\\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\\\">Username</th>\\n <th className=\\\"pb-2 pr-4\\\">Role</th>\\n <th className=\\\"pb-2 pr-4\\\">Created</th>\\n <th className=\\\"pb-2\\\">Actions</th>\\n </tr>\\n </thead>\\n <tbody>\\n {users.map((u) => (\\n <tr key={u.id} className=\\\"border-t border-surface-elevated\\\">\\n <td className=\\\"py-2 pr-4 text-white\\\">{u.username}</td>\\n <td className=\\\"py-2 pr-4\\\">\\n <select\\n value={u.role}\\n onChange={(e) => handleRoleChange(u.id, e.target.value)}\\n className=\\\"bg-surface-elevated text-white text-xs rounded px-2 py-1 border border-surface-elevated\\\"\\n >\\n <option value=\\\"admin\\\">Admin</option>\\n <option value=\\\"user\\\">User</option>\\n <option value=\\\"viewer\\\">Viewer</option>\\n </select>\\n </td>\\n <td className=\\\"py-2 pr-4 text-gray-400 text-xs\\\">{new Date(u.created_at).toLocaleDateString()}</td>\\n <td className=\\\"py-2 flex gap-2\\\">\\n <button onClick={() => setResetPw({ id: u.id, pw: \\\"\\\" })} className=\\\"px-2 py-1 rounded text-xs bg-surface-elevated hover:bg-surface-elevated/80 text-gray-300 transition-colors\\\">\\n Reset PW\\n </button>\\n {u.username !== selfUsername && (\\n <button onClick={() => handleDelete(u.id)} className=\\\"px-2 py-1 rounded text-xs bg-red-900/50 text-red-300 hover:bg-red-800/60 transition-colors\\\">\\n Delete\\n </button>\\n )}\\n </td>\\n </tr>\\n ))}\\n </tbody>\\n </table>\\n\\n {resetPw && (\\n <div className=\\\"p-4 rounded-lg bg-surface-elevated border border-surface-elevated max-w-sm\\\">\\n <p className=\\\"text-sm text-white mb-3\\\">Set new password</p>\\n <div className=\\\"flex gap-2\\\">\\n <input\\n type=\\\"password\\\"\\n placeholder=\\\"New password\\\"\\n value={resetPw.pw}\\n onChange={(e) => setResetPw({ ...resetPw, pw: e.target.value })}\\n className=\\\"flex-1 px-3 py-1.5 rounded bg-surface text-white text-sm border border-surface-elevated focus:outline-none focus:border-accent\\\"\\n />\\n <button onClick={() => handleResetPassword(resetPw.id, resetPw.pw)} className=\\\"px-3 py-1.5 rounded bg-accent hover:bg-accent-hover text-xs font-medium\\\">Save</button>\\n <button onClick={() => setResetPw(null)} className=\\\"px-3 py-1.5 rounded bg-surface text-gray-400 text-xs\\\">Cancel</button>\\n </div>\\n </div>\\n )}\\n\\n <div className=\\\"border-t border-surface-elevated pt-4\\\">\\n <p className=\\\"text-sm text-white mb-3 font-medium\\\">Add User</p>\\n <form onSubmit={handleCreate} className=\\\"flex gap-2 items-end flex-wrap\\\">\\n <input\\n type=\\\"text\\\"\\n placeholder=\\\"Username\\\"\\n value={newUser.username}\\n onChange={(e) => setNewUser({ ...newUser, username: e.target.value })}\\n required\\n className=\\\"px-3 py-2 rounded-lg bg-surface-elevated text-white text-sm border border-surface-elevated focus:outline-none focus:border-accent\\\"\\n />\\n <input\\n type=\\\"password\\\"\\n placeholder=\\\"Password\\\"\\n value={newUser.password}\\n onChange={(e) => setNewUser({ ...newUser, password: e.target.value })}\\n required\\n className=\\\"px-3 py-2 rounded-lg bg-surface-elevated text-white text-sm border border-surface-elevated focus:outline-none focus:border-accent\\\"\\n />\\n <select\\n value={newUser.role}\\n onChange={(e) => setNewUser({ ...newUser, role: e.target.value })}\\n className=\\\"px-3 py-2 rounded-lg bg-surface-elevated text-white text-sm border border-surface-elevated\\\"\\n >\\n <option value=\\\"admin\\\">Admin</option>\\n <option value=\\\"user\\\">User</option>\\n <option value=\\\"viewer\\\">Viewer</option>\\n </select>\\n <button type=\\\"submit\\\" disabled={creating} className=\\\"px-4 py-2 rounded-lg bg-accent hover:bg-accent-hover text-sm font-medium disabled:opacity-50 transition-colors\\\">\\n {creating ? \\\"Adding…\\\" : \\\"Add User\\\"}\\n </button>\\n </form>\\n </div>\\n </div>\\n );\\n}\\n\\n// ── Main Settings Page ──────────────────────────────────────────────────\\n\\ntype Tab = \\\"vpn\\\" | \\\"hosts\\\" | \\\"sessions\\\" | \\\"users\\\";\\n\\nexport function Settings({ onClose }: { onClose: () => void }) {\\n const [tab, setTab] = useState<Tab>(\\\"vpn\\\");\\n\\n const tabs: { id: Tab; label: string }[] = [\\n { id: \\\"vpn\\\", label: \\\"VPN\\\" },\\n { id: \\\"hosts\\\", label: \\\"Hosts\\\" },\\n { id: \\\"sessions\\\", label: \\\"Sessions\\\" },\\n { id: \\\"users\\\", label: \\\"Users\\\" },\\n ];\\n\\n return (\\n <div className=\\\"flex flex-col h-screen bg-surface\\\">\\n <div className=\\\"flex items-center justify-between px-4 py-3 bg-surface-raised border-b border-surface-elevated\\\">\\n <h2 className=\\\"font-semibold text-white\\\">Admin Settings</h2>\\n <button onClick={onClose} className=\\\"text-gray-400 hover:text-white text-sm transition-colors\\\">\\n ← Back\\n </button>\\n </div>\\n\\n <div className=\\\"flex gap-1 px-4 pt-3 border-b border-surface-elevated\\\">\\n {tabs.map((t) => (\\n <button\\n key={t.id}\\n onClick={() => setTab(t.id)}\\n className={`px-4 py-2 text-sm font-medium rounded-t-lg transition-colors ${\\n tab === t.id\\n ? \\\"bg-surface-raised text-white border border-b-0 border-surface-elevated\\\"\\n : \\\"text-gray-400 hover:text-white\\\"\\n }`}\\n >\\n {t.label}\\n </button>\\n ))}\\n </div>\\n\\n <div className=\\\"flex-1 overflow-y-auto p-6\\\">\\n {tab === \\\"vpn\\\" && <VpnSection />}\\n {tab === \\\"hosts\\\" && <HostsSection />}\\n {tab === \\\"sessions\\\" && <SessionsTable />}\\n {tab === \\\"users\\\" && <UsersSection />}\\n </div>\\n </div>\\n );\\n}\\n\"}"}]},"uuid":"fe5e93a6-8f93-4605-9d91-7713023273ad","timestamp":"2026-05-06T03:20:54.130Z","sourceToolAssistantUUID":"24027799-e2a5-49e6-a4c5-82c9f31f99c6","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,"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","version":"2.1.129","gitBranch":"HEAD"}
|
||
{"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 border-surface-elevated text-gray-500 hover:text-gray-300 hover:border-gray-500 transition-colors duration-150\"\n >\n <span className=\"text-2xl\">+</span>\n <span className=\"text-xs\">Add host</span>\n </button>\n </RequireRole>\n </div>\n )}\n </main>\n\n {showAddModal && (\n <AddHostModal\n onClose={() => setShowAddModal(false)}\n onAdded={refresh}\n />\n )}\n </div>\n );\n}\n```\n\n## src/api.ts\n\n```typescript\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 getSessions: () => request<Session[]>(\"GET\", \"/api/admin/sessions\"),\n\n // Admin\n getAdminConfig: () => request<AdminConfig>(\"GET\", \"/api/admin/config\"),\n updateAdminConfig: (patch: Partial<AdminConfig>) =>\n request<{ ok: boolean; restart_required: boolean }>(\"PUT\", \"/api/admin/config\", patch),\n disconnectSession: (id: string) =>\n request<{ ok: boolean }>(\"POST\", `/api/admin/sessions/${id}/disconnect`),\n\n // User management\n listUsers: () => request<UserRecord[]>(\"GET\", \"/api/admin/users\"),\n createUser: (username: string, password: string, role: string) =>\n request<{ ok: boolean }>(\"POST\", \"/api/admin/users\", { username, password, role }),\n updateUser: (id: number, patch: { role?: string; password?: string }) =>\n request<{ ok: boolean }>(\"PUT\", `/api/admin/users/${id}`, patch),\n deleteUser: (id: number) =>\n request<{ ok: boolean }>(\"DELETE\", `/api/admin/users/${id}`),\n};\n```\n\n## src/pages/Settings.tsx\n\n```typescript\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.length} host{hosts.length !== 1 ? \"s\" : \"\"}</span>\n <button onClick={() => setShowAdd(true)} className=\"px-3 py-1.5 rounded-lg bg-accent hover:bg-accent-hover text-xs font-medium transition-colors\">\n + Add Host\n </button>\n </div>\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\">Name</th>\n <th className=\"pb-2 pr-4\">IP</th>\n <th className=\"pb-2 pr-4\">Port</th>\n <th className=\"pb-2 pr-4\">Status</th>\n <th className=\"pb-2\">Actions</th>\n </tr>\n </thead>\n <tbody>\n {hosts.map((h) => (\n <tr key={h.ip} className=\"border-t border-surface-elevated\">\n <td className=\"py-2 pr-4 text-white\">{h.name}</td>\n <td className=\"py-2 pr-4 text-gray-400 font-mono text-xs\">{h.ip}</td>\n <td className=\"py-2 pr-4 text-gray-400 text-xs\">{h.port}</td>\n <td className=\"py-2 pr-4\">\n <span className={`text-xs px-2 py-0.5 rounded-full ${h.online ? \"bg-green-900/50 text-green-300\" : \"bg-gray-700 text-gray-400\"}`}>\n {h.online ? \"Online\" : \"Offline\"}\n </span>\n </td>\n <td className=\"py-2\">\n <button onClick={() => handleRemove(h.ip)} className=\"px-3 py-1 rounded-lg text-xs bg-red-900/50 text-red-300 hover:bg-red-800/60 transition-colors\">\n Remove\n </button>\n </td>\n </tr>\n ))}\n </tbody>\n </table>\n {showAdd && <AddHostModal onClose={() => setShowAdd(false)} onAdded={refresh} />}\n </div>\n );\n}\n\n// ── Section D: Users ───────────────────────────────────────────────────\n\nfunction UsersSection() {\n const { username: selfUsername } = useAuth();\n const [users, setUsers] = useState<UserRecord[]>([]);\n const [newUser, setNewUser] = useState({ username: \"\", password: \"\", role: \"user\" });\n const [creating, setCreating] = useState(false);\n const [resetPw, setResetPw] = useState<{ id: number; pw: string } | null>(null);\n\n function refresh() { api.listUsers().then(setUsers).catch(() => {}); }\n useEffect(() => { refresh(); }, []);\n\n async function handleCreate(e: FormEvent) {\n e.preventDefault();\n setCreating(true);\n try {\n await api.createUser(newUser.username, newUser.password, newUser.role);\n setNewUser({ username: \"\", password: \"\", role: \"user\" });\n refresh();\n } finally {\n setCreating(false);\n }\n }\n\n async function handleRoleChange(id: number, role: string) {\n await api.updateUser(id, { role });\n refresh();\n }\n\n async function handleResetPassword(id: number, pw: string) {\n await api.updateUser(id, { password: pw });\n setResetPw(null);\n }\n\n async function handleDelete(id: number) {\n await api.deleteUser(id);\n refresh();\n }\n\n return (\n <div className=\"flex flex-col gap-6\">\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\">Username</th>\n <th className=\"pb-2 pr-4\">Role</th>\n <th className=\"pb-2 pr-4\">Created</th>\n <th className=\"pb-2\">Actions</th>\n </tr>\n </thead>\n <tbody>\n {users.map((u) => (\n <tr key={u.id} className=\"border-t border-surface-elevated\">\n <td className=\"py-2 pr-4 text-white\">{u.username}</td>\n <td className=\"py-2 pr-4\">\n <select\n value={u.role}\n onChange={(e) => handleRoleChange(u.id, e.target.value)}\n className=\"bg-surface-elevated text-white text-xs rounded px-2 py-1 border border-surface-elevated\"\n >\n <option value=\"admin\">Admin</option>\n <option value=\"user\">User</option>\n <option value=\"viewer\">Viewer</option>\n </select>\n </td>\n <td className=\"py-2 pr-4 text-gray-400 text-xs\">{new Date(u.created_at).toLocaleDateString()}</td>\n <td className=\"py-2 flex gap-2\">\n <button onClick={() => setResetPw({ id: u.id, pw: \"\" })} className=\"px-2 py-1 rounded text-xs bg-surface-elevated hover:bg-surface-elevated/80 text-gray-300 transition-colors\">\n Reset PW\n </button>\n {u.username !== selfUsername && (\n <button onClick={() => handleDelete(u.id)} className=\"px-2 py-1 rounded text-xs bg-red-900/50 text-red-300 hover:bg-red-800/60 transition-colors\">\n Delete\n </button>\n )}\n </td>\n </tr>\n ))}\n </tbody>\n </table>\n\n {resetPw && (\n <div className=\"p-4 rounded-lg bg-surface-elevated border border-surface-elevated max-w-sm\">\n <p className=\"text-sm text-white mb-3\">Set new password</p>\n <div className=\"flex gap-2\">\n <input\n type=\"password\"\n placeholder=\"New password\"\n value={resetPw.pw}\n onChange={(e) => setResetPw({ ...resetPw, pw: e.target.value })}\n className=\"flex-1 px-3 py-1.5 rounded bg-surface text-white text-sm border border-surface-elevated focus:outline-none focus:border-accent\"\n />\n <button onClick={() => handleResetPassword(resetPw.id, resetPw.pw)} className=\"px-3 py-1.5 rounded bg-accent hover:bg-accent-hover text-xs font-medium\">Save</button>\n <button onClick={() => setResetPw(null)} className=\"px-3 py-1.5 rounded bg-surface text-gray-400 text-xs\">Cancel</button>\n </div>\n </div>\n )}\n\n <div className=\"border-t border-surface-elevated pt-4\">\n <p className=\"text-sm text-white mb-3 font-medium\">Add User</p>\n <form onSubmit={handleCreate} className=\"flex gap-2 items-end flex-wrap\">\n <input\n type=\"text\"\n placeholder=\"Username\"\n value={newUser.username}\n onChange={(e) => setNewUser({ ...newUser, username: e.target.value })}\n required\n className=\"px-3 py-2 rounded-lg bg-surface-elevated text-white text-sm border border-surface-elevated focus:outline-none focus:border-accent\"\n />\n <input\n type=\"password\"\n placeholder=\"Password\"\n value={newUser.password}\n onChange={(e) => setNewUser({ ...newUser, password: e.target.value })}\n required\n className=\"px-3 py-2 rounded-lg bg-surface-elevated text-white text-sm border border-surface-elevated focus:outline-none focus:border-accent\"\n />\n <select\n value={newUser.role}\n onChange={(e) => setNewUser({ ...newUser, role: e.target.value })}\n className=\"px-3 py-2 rounded-lg bg-surface-elevated text-white text-sm border border-surface-elevated\"\n >\n <option value=\"admin\">Admin</option>\n <option value=\"user\">User</option>\n <option value=\"viewer\">Viewer</option>\n </select>\n <button type=\"submit\" disabled={creating} className=\"px-4 py-2 rounded-lg bg-accent hover:bg-accent-hover text-sm font-medium disabled:opacity-50 transition-colors\">\n {creating ? \"Adding…\" : \"Add User\"}\n </button>\n </form>\n </div>\n </div>\n );\n}\n\n// ── Main Settings Page ──────────────────────────────────────────────────\n\ntype Tab = \"vpn\" | \"hosts\" | \"sessions\" | \"users\";\n\nexport function Settings({ onClose }: { onClose: () => void }) {\n const [tab, setTab] = useState<Tab>(\"vpn\");\n\n const tabs: { id: Tab; label: string }[] = [\n { id: \"vpn\", label: \"VPN\" },\n { id: \"hosts\", label: \"Hosts\" },\n { id: \"sessions\", label: \"Sessions\" },\n { id: \"users\", label: \"Users\" },\n ];\n\n return (\n <div className=\"flex flex-col h-screen bg-surface\">\n <div className=\"flex items-center justify-between px-4 py-3 bg-surface-raised border-b border-surface-elevated\">\n <h2 className=\"font-semibold text-white\">Admin Settings</h2>\n <button onClick={onClose} className=\"text-gray-400 hover:text-white text-sm transition-colors\">\n ← Back\n </button>\n </div>\n\n <div className=\"flex gap-1 px-4 pt-3 border-b border-surface-elevated\">\n {tabs.map((t) => (\n <button\n key={t.id}\n onClick={() => setTab(t.id)}\n className={`px-4 py-2 text-sm font-medium rounded-t-lg transition-colors ${\n tab === t.id\n ? \"bg-surface-raised text-white border border-b-0 border-surface-elevated\"\n : \"text-gray-400 hover:text-white\"\n }`}\n >\n {t.label}\n </button>\n ))}\n </div>\n\n <div className=\"flex-1 overflow-y-auto p-6\">\n {tab === \"vpn\" && <VpnSection />}\n {tab === \"hosts\" && <HostsSection />}\n {tab === \"sessions\" && <SessionsTable />}\n {tab === \"users\" && <UsersSection />}\n </div>\n </div>\n );\n}\n```\n\n## src/pages/Login.tsx\n\n```typescript\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```\n\n## src/components/HostCard.tsx\n\n```typescript\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```\n\n## src/components/SessionsTable.tsx\n\n```typescript\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```\n\n## src/components/StatusBar.tsx\n\n```typescript\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```\n\n## src/components/AddHostModal.tsx\n\n```typescript\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```"}],"stop_reason":"end_turn","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":7,"cache_creation_input_tokens":13453,"cache_read_input_tokens":94586,"output_tokens":10346,"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":13453},"inference_geo":"","iterations":[{"input_tokens":7,"output_tokens":10346,"cache_read_input_tokens":94586,"cache_creation_input_tokens":13453,"cache_creation":{"ephemeral_5m_input_tokens":13453,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"}},"requestId":"req_011CakbW3CuXQ8KfkSMFRrup","attributionAgent":"Explore","type":"assistant","uuid":"7925bed8-609d-43ed-9fb0-7afa4a00a60e","timestamp":"2026-05-06T03:22:02.695Z","userType":"external","entrypoint":"sdk-ts","cwd":"/home/node","sessionId":"9b13371c-0b62-457e-a8bf-a331b2d5b744","version":"2.1.129","gitBranch":"HEAD"}
|