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 (
-
-
-
-
-
- |
- Provider
- |
-
- Channel
- |
-
- Action
- |
-
- Created At
- |
-
- Actions
- |
-
-
-
- {integrations.map((integration) => (
-
- |
- {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.
+
+ ) : (
+
+
+
+
+
+ |
+ Provider
+ |
+
+ Channel
+ |
+
+ Action
+ |
+
+ Created At
+ |
+
+ Actions
+ |
+
+
+
+ {integrations.map((integration) => (
+
+ |
+ {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 = (