diff --git a/scripts/setup_custom_domain.sh b/scripts/setup_custom_domain.sh index 58ebb8c..55d22bc 100755 --- a/scripts/setup_custom_domain.sh +++ b/scripts/setup_custom_domain.sh @@ -207,6 +207,28 @@ server { ssl_prefer_server_ciphers on; ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; + # Backend API and WebSockets — bypass the UI, go straight to api:8000 + location /api/v1/ { + proxy_pass http://api:8000; + proxy_http_version 1.1; + + proxy_set_header Upgrade \$http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_set_header Host \$host; + proxy_set_header X-Real-IP \$remote_addr; + proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + + # Long-lived WebSockets (audio streaming, signaling) + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + + # Don't buffer streamed responses + proxy_buffering off; + client_max_body_size 100M; + } + location / { proxy_pass http://ui:3010; proxy_http_version 1.1; @@ -248,16 +270,12 @@ echo -e "${GREEN}✓ nginx.conf updated${NC}" # Update .env file with domain name echo -e "${BLUE}[6/8] Updating environment variables...${NC}" if [[ -f ".env" ]]; then - # Update BACKEND_API_ENDPOINT to use domain + # Update BACKEND_API_ENDPOINT to use domain (public URL the backend advertises) sed -i.bak "s|^BACKEND_API_ENDPOINT=.*|BACKEND_API_ENDPOINT=https://$DOMAIN_NAME|" .env - # Update BACKEND_URL if present, otherwise add it - if grep -q "^BACKEND_URL=" .env; then - sed -i.bak "s|^BACKEND_URL=.*|BACKEND_URL=https://$DOMAIN_NAME|" .env - else - echo "" >> .env - echo "# Backend URL for UI" >> .env - echo "BACKEND_URL=https://$DOMAIN_NAME" >> .env - fi + # Drop any stale BACKEND_URL override — the ui container should use the + # internal Docker URL (http://api:8000) from docker-compose defaults. + sed -i.bak "/^BACKEND_URL=/d" .env + sed -i.bak "/^# Backend URL for UI$/d" .env # Update TURN_HOST to use domain sed -i.bak "s|^TURN_HOST=.*|TURN_HOST=$DOMAIN_NAME|" .env rm -f .env.bak diff --git a/scripts/setup_remote.sh b/scripts/setup_remote.sh index a5d7b3d..652016e 100755 --- a/scripts/setup_remote.sh +++ b/scripts/setup_remote.sh @@ -15,9 +15,11 @@ echo "║ Automated HTTPS deployment with TURN server ║" echo "╚══════════════════════════════════════════════════════════════╝" echo -e "${NC}" -# Get the public IP address -echo -e "${YELLOW}Enter your server's public IP address:${NC}" -read -p "> " SERVER_IP +# Get the public IP address (skip prompt if SERVER_IP is already set) +if [[ -z "$SERVER_IP" ]]; then + echo -e "${YELLOW}Enter your server's public IP address:${NC}" + read -p "> " SERVER_IP +fi if [[ -z "$SERVER_IP" ]]; then echo -e "${RED}Error: IP address cannot be empty${NC}" @@ -30,29 +32,39 @@ if ! [[ "$SERVER_IP" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then exit 1 fi -# Get the TURN secret -echo -e "${YELLOW}Enter a shared secret for the TURN server (press Enter to generate a random one):${NC}" -read -sp "> " TURN_SECRET -echo "" +# Get the TURN secret (skip prompt if TURN_SECRET is already set) +if [[ -z "$TURN_SECRET" ]]; then + echo -e "${YELLOW}Enter a shared secret for the TURN server (press Enter to generate a random one):${NC}" + read -sp "> " TURN_SECRET + echo "" +fi if [[ -z "$TURN_SECRET" ]]; then TURN_SECRET=$(openssl rand -hex 32) echo -e "${BLUE}Generated random TURN secret${NC}" fi +# Telemetry opt-out (default: true) +ENABLE_TELEMETRY="${ENABLE_TELEMETRY:-true}" + echo "" echo -e "${GREEN}Configuration:${NC}" echo -e " Server IP: ${BLUE}$SERVER_IP${NC}" echo -e " TURN Secret: ${BLUE}********${NC}" echo "" -# Create project directory if it doesn't exist -mkdir -p dograh 2>/dev/null || true -cd dograh +# Create project directory and download compose file (skip when +# DOGRAH_SKIP_DOWNLOAD=1 — e.g. e2e tests that already have a cloned repo). +if [[ "$DOGRAH_SKIP_DOWNLOAD" != "1" ]]; then + mkdir -p dograh 2>/dev/null || true + cd dograh -echo -e "${BLUE}[1/5] Downloading docker-compose.yaml...${NC}" -curl -sS -o docker-compose.yaml https://raw.githubusercontent.com/dograh-hq/dograh/main/docker-compose.yaml -echo -e "${GREEN}✓ docker-compose.yaml downloaded${NC}" + echo -e "${BLUE}[1/5] Downloading docker-compose.yaml...${NC}" + curl -sS -o docker-compose.yaml https://raw.githubusercontent.com/dograh-hq/dograh/main/docker-compose.yaml + echo -e "${GREEN}✓ docker-compose.yaml downloaded${NC}" +else + echo -e "${BLUE}[1/5] Using docker-compose.yaml in current directory${NC}" +fi echo -e "${BLUE}[2/5] Creating nginx.conf...${NC}" cat > nginx.conf << 'NGINX_EOF' @@ -75,6 +87,28 @@ server { ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers on; + # Backend API and WebSockets — bypass the UI, go straight to api:8000 + location /api/v1/ { + proxy_pass http://api:8000; + proxy_http_version 1.1; + + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + + # Long-lived WebSockets (audio streaming, signaling) + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + + # Don't buffer streamed responses + proxy_buffering off; + client_max_body_size 100M; + } + location / { proxy_pass http://ui:3010; proxy_http_version 1.1; @@ -170,12 +204,9 @@ echo -e "${BLUE}[6/6] Creating environment file...${NC}" OSS_JWT_SECRET=$(openssl rand -hex 32) cat > .env << ENV_EOF -# Backend API endpoint (for remote deployment) +# Backend API endpoint (public URL the backend uses to build webhook/embed links) BACKEND_API_ENDPOINT=https://$SERVER_IP -# Backend URL for UI -BACKEND_URL=https://$SERVER_IP - # TURN Server Configuration (time-limited credentials via TURN REST API) TURN_HOST=$SERVER_IP TURN_SECRET=$TURN_SECRET @@ -184,7 +215,7 @@ TURN_SECRET=$TURN_SECRET OSS_JWT_SECRET=$OSS_JWT_SECRET # Telemetry (set to false to disable) -ENABLE_TELEMETRY=true +ENABLE_TELEMETRY=$ENABLE_TELEMETRY ENV_EOF echo -e "${GREEN}✓ .env file created${NC}" diff --git a/ui/src/app/api/config/version/route.ts b/ui/src/app/api/config/version/route.ts index a039360..ef4aa1e 100644 --- a/ui/src/app/api/config/version/route.ts +++ b/ui/src/app/api/config/version/route.ts @@ -6,19 +6,10 @@ import type { HealthResponse } from "@/client/types.gen"; // Import version from package.json at build time import packageJson from "../../../../../package.json"; -// Internal/local URLs that are not reachable from the browser -const INTERNAL_HOST_RE = /^https?:\/\/(localhost|127\.0\.0\.1|api)(:\d+)?(\/|$)/; - -function isInternalUrl(url: string | undefined | null): boolean { - return !url || INTERNAL_HOST_RE.test(url); -} - export async function GET() { const uiVersion = packageJson.version || "dev"; - // Fetch backend version and config from health endpoint let apiVersion = "unknown"; - let backendApiEndpoint: string | null = null; let deploymentMode = "oss"; let authProvider = "local"; @@ -27,28 +18,16 @@ export async function GET() { if (response.data) { const data = response.data as HealthResponse; apiVersion = data.version; - // Pass through the backend's own endpoint for display purposes - backendApiEndpoint = data.backend_api_endpoint; deploymentMode = data.deployment_mode; authProvider = data.auth_provider; } } catch { - // Backend might not be reachable during build or in some deployments apiVersion = "unavailable"; } - // For the API client base URL: prefer BACKEND_URL env, fall back to - // health endpoint value. Skip internal/Docker-only URLs (e.g. http://api:8000) - // that aren't reachable from the browser — the client will keep using - // window.location.origin via the Next.js proxy instead. - const clientCandidate = process.env.BACKEND_URL || backendApiEndpoint; - const clientApiBaseUrl = isInternalUrl(clientCandidate) ? 'http://localhost:8000' : clientCandidate; - return NextResponse.json({ ui: uiVersion, api: apiVersion, - backendApiEndpoint, - clientApiBaseUrl, deploymentMode, authProvider, }); diff --git a/ui/src/app/integrations/page.tsx b/ui/src/app/integrations/page.tsx index 1277833..74aee68 100644 --- a/ui/src/app/integrations/page.tsx +++ b/ui/src/app/integrations/page.tsx @@ -1,140 +1,15 @@ +'use client'; + import Link from 'next/link'; -import { Suspense } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { getIntegrationsApiV1IntegrationGet } from "@/client/sdk.gen"; -import { getServerAccessToken,getServerAuthProvider } from '@/lib/auth/server'; +import type { IntegrationResponse } from '@/client/types.gen'; +import { useAuth } from '@/lib/auth'; import logger from '@/lib/logger'; import CreateIntegrationButton from "./CreateIntegrationButton"; -export const dynamic = 'force-dynamic'; - -// Server component for integration list -async function IntegrationList() { - const authProvider = await getServerAuthProvider(); - const accessToken = await getServerAccessToken(); - - if (!accessToken) { - const { redirect } = await import('next/navigation'); - if (authProvider === 'stack') { - redirect('/'); - } else { - // For OSS mode, this shouldn't happen as token is auto-generated - return ( -
- Authentication required. Please refresh the page. -
- ); - } - } - - try { - const response = await getIntegrationsApiV1IntegrationGet({ - headers: { - 'Authorization': `Bearer ${accessToken}`, - }, - }); - - const integrationData = response.data ? (Array.isArray(response.data) ? response.data : [response.data]) : []; - const integrations = [...integrationData].sort((a, b) => - new Date(b.created_at).getTime() - new Date(a.created_at).getTime() - ); - - if (integrations.length === 0) { - return ( -
- No integrations found. Create your first integration to get started. -
- ); - } - - return ( -
-
- - - - - - - - - - - - {integrations.map((integration) => ( - - - - - - - - ))} - -
- Provider - - Channel - - Action - - Created At - - Actions -
- {integration.provider} - - {integration.provider === 'slack' && integration.provider_data ? (integration.provider_data.channel as string) || '-' : '-'} - - {integration.action} - - {new Date(integration.created_at).toLocaleDateString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - })} - - {integration.provider === 'google-mail' && ( - - Search - - )} -
-
-
- ); - } catch (err) { - logger.error(`Error fetching integrations: ${err}`); - return ( -
- Failed to load Integrations. Please Try Again Later. -
- ); - } -} - -async function PageContent() { - const integrationList = await IntegrationList(); - - return ( -
-
-
-

Your Integrations

- -
- {integrationList} -
-
- ); -} - function IntegrationsLoading() { return (
@@ -187,9 +62,113 @@ function IntegrationsLoading() { } export default function IntegrationsPage() { + const { user, loading: authLoading } = useAuth(); + const hasFetched = useRef(false); + const [integrations, setIntegrations] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (authLoading || !user || hasFetched.current) return; + hasFetched.current = true; + + const fetchIntegrations = async () => { + try { + const response = await getIntegrationsApiV1IntegrationGet({}); + + const integrationData = response.data ? (Array.isArray(response.data) ? response.data : [response.data]) : []; + const sorted = [...integrationData].sort((a, b) => + new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + ); + setIntegrations(sorted); + } catch (err) { + logger.error(`Error fetching integrations: ${err}`); + setError('Failed to load Integrations. Please Try Again Later.'); + } + }; + + fetchIntegrations(); + }, [authLoading, user]); + + if (authLoading || (integrations === null && !error)) { + return ; + } + return ( - }> - - +
+
+
+

Your Integrations

+ +
+ + {error ? ( +
{error}
+ ) : !integrations || integrations.length === 0 ? ( +
+ No integrations found. Create your first integration to get started. +
+ ) : ( +
+
+ + + + + + + + + + + + {integrations.map((integration) => ( + + + + + + + + ))} + +
+ Provider + + Channel + + Action + + Created At + + Actions +
+ {integration.provider} + + {integration.provider === 'slack' && integration.provider_data ? (integration.provider_data.channel as string) || '-' : '-'} + + {integration.action} + + {new Date(integration.created_at).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + })} + + {integration.provider === 'google-mail' && ( + + Search + + )} +
+
+
+ )} +
+
); } diff --git a/ui/src/app/looptalk/[id]/page.tsx b/ui/src/app/looptalk/[id]/page.tsx index 8bbfff7..0fc3417 100644 --- a/ui/src/app/looptalk/[id]/page.tsx +++ b/ui/src/app/looptalk/[id]/page.tsx @@ -1,99 +1,22 @@ +'use client'; + import { ArrowLeft } from 'lucide-react'; import Link from 'next/link'; -import { notFound } from 'next/navigation'; -import { Suspense } from 'react'; +import { useParams } from 'next/navigation'; +import { useEffect, useRef, useState } from 'react'; import { getTestSessionApiV1LooptalkTestSessionsTestSessionIdGet } from '@/client/sdk.gen'; +import type { TestSessionResponse } from '@/client/types.gen'; import { ConversationsList } from '@/components/looptalk/ConversationsList'; import { LiveAudioPlayer } from '@/components/looptalk/LiveAudioPlayer'; import { TestSessionControls } from '@/components/looptalk/TestSessionControls'; import { TestSessionDetails } from '@/components/looptalk/TestSessionDetails'; import { Button } from '@/components/ui/button'; -import { getServerAccessToken,getServerAuthProvider } from '@/lib/auth/server'; +import { useAuth } from '@/lib/auth'; import logger from '@/lib/logger'; import LoopTalkLayout from "../LoopTalkLayout"; -interface PageProps { - params: Promise<{ - id: string; - }>; -} - -async function PageContent({ params }: PageProps) { - const authProvider = await getServerAuthProvider(); - const accessToken = await getServerAccessToken(); - - if (!accessToken) { - const { redirect } = await import('next/navigation'); - if (authProvider === 'stack') { - redirect('/'); - } else { - // For OSS mode, this shouldn't happen as token is auto-generated - return ( -
- Authentication required. Please refresh the page. -
- ); - } - } - - try { - const resolvedParams = await params; - const testSessionId = parseInt(resolvedParams.id); - const response = await getTestSessionApiV1LooptalkTestSessionsTestSessionIdGet({ - path: { - test_session_id: testSessionId - }, - headers: { - 'Authorization': `Bearer ${accessToken}`, - }, - }); - - const testSession = response.data; - - if (!testSession) { - notFound(); - } - - // Transform the API response to match our UI types - const sessionForUI = { - id: testSession.id, - name: testSession.name, - description: '', // API doesn't return description - test_type: testSession.test_index !== null ? 'load_test' : 'single', - status: testSession.status, - actor_workflow_name: `Workflow ${testSession.actor_workflow_id}`, // We'll need to fetch actual names - adversary_workflow_name: `Workflow ${testSession.adversary_workflow_id}`, - created_at: testSession.created_at, - updated_at: testSession.created_at, // API doesn't have updated_at - test_metadata: testSession.config - }; - - return ( -
- - - {/* Persistent Audio Player */} -
- -
-
-

Conversations

- -
-
- ); - } catch (err) { - logger.error(`Error fetching test session: ${err}`); - notFound(); - } -} - function TestSessionLoading() { return (
@@ -106,7 +29,87 @@ function TestSessionLoading() { ); } -export default function TestSessionPage({ params }: PageProps) { +function TestSessionPageContent() { + const params = useParams(); + const testSessionId = parseInt(params.id as string); + const { user, loading: authLoading } = useAuth(); + const hasFetched = useRef(false); + const [testSession, setTestSession] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (authLoading || !user || hasFetched.current) return; + hasFetched.current = true; + + const fetchTestSession = async () => { + try { + const response = await getTestSessionApiV1LooptalkTestSessionsTestSessionIdGet({ + path: { + test_session_id: testSessionId + }, + }); + + if (!response.data) { + setError('Test session not found'); + return; + } + setTestSession(response.data); + } catch (err) { + logger.error(`Error fetching test session: ${err}`); + setError('Failed to load test session'); + } + }; + + fetchTestSession(); + }, [authLoading, user, testSessionId]); + + if (authLoading || (testSession === null && !error)) { + return ; + } + + if (error || !testSession) { + return ( +
+
+ {error || 'Test session not found'} +
+
+ ); + } + + const sessionForUI = { + id: testSession.id, + name: testSession.name, + description: '', + test_type: testSession.test_index !== null ? 'load_test' : 'single', + status: testSession.status, + actor_workflow_name: `Workflow ${testSession.actor_workflow_id}`, + adversary_workflow_name: `Workflow ${testSession.adversary_workflow_id}`, + created_at: testSession.created_at, + updated_at: testSession.created_at, + test_metadata: testSession.config + }; + + return ( +
+ + +
+ +
+
+

Conversations

+ +
+
+ ); +} + +export default function TestSessionPage() { const backButton = (