mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-07 07:55:16 +02:00
fix: ssl error when using self signed certificate (#238)
fix: ssl error when using self signed certificate with remote deployment
This commit is contained in:
parent
7fab959e26
commit
50a59164e7
10 changed files with 285 additions and 292 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="text-red-500">
|
||||
Authentication required. Please refresh the page.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No integrations found. Create your first integration to get started.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full bg-card border border-border">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Provider
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Channel
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Action
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Created At
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-card divide-y divide-border">
|
||||
{integrations.map((integration) => (
|
||||
<tr key={integration.id} className="hover:bg-muted/50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
{integration.provider}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||
{integration.provider === 'slack' && integration.provider_data ? (integration.provider_data.channel as string) || '-' : '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||
{integration.action}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||
{new Date(integration.created_at).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||
{integration.provider === 'google-mail' && (
|
||||
<Link
|
||||
href={`/integrations/${integration.id}/gmail`}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Search
|
||||
</Link>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(`Error fetching integrations: ${err}`);
|
||||
return (
|
||||
<div className="text-red-500 text-center py-8">
|
||||
Failed to load Integrations. Please Try Again Later.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function PageContent() {
|
||||
const integrationList = await IntegrationList();
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold">Your Integrations</h1>
|
||||
<CreateIntegrationButton />
|
||||
</div>
|
||||
{integrationList}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IntegrationsLoading() {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
|
|
@ -187,9 +62,113 @@ function IntegrationsLoading() {
|
|||
}
|
||||
|
||||
export default function IntegrationsPage() {
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const hasFetched = useRef(false);
|
||||
const [integrations, setIntegrations] = useState<IntegrationResponse[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(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 <IntegrationsLoading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<IntegrationsLoading />}>
|
||||
<PageContent />
|
||||
</Suspense>
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold">Your Integrations</h1>
|
||||
<CreateIntegrationButton />
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="text-red-500 text-center py-8">{error}</div>
|
||||
) : !integrations || integrations.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No integrations found. Create your first integration to get started.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full bg-card border border-border">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Provider
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Channel
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Action
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Created At
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-card divide-y divide-border">
|
||||
{integrations.map((integration) => (
|
||||
<tr key={integration.id} className="hover:bg-muted/50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
{integration.provider}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||
{integration.provider === 'slack' && integration.provider_data ? (integration.provider_data.channel as string) || '-' : '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||
{integration.action}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||
{new Date(integration.created_at).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||
{integration.provider === 'google-mail' && (
|
||||
<Link
|
||||
href={`/integrations/${integration.id}/gmail`}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Search
|
||||
</Link>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="text-red-500">
|
||||
Authentication required. Please refresh the page.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<TestSessionDetails session={sessionForUI} />
|
||||
<TestSessionControls session={sessionForUI} />
|
||||
{/* Persistent Audio Player */}
|
||||
<div className="mt-6">
|
||||
<LiveAudioPlayer
|
||||
testSessionId={testSessionId}
|
||||
sessionStatus={testSession.status as 'pending' | 'running' | 'completed' | 'failed'}
|
||||
autoStart={true}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold mb-4">Conversations</h2>
|
||||
<ConversationsList testSessionId={testSessionId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(`Error fetching test session: ${err}`);
|
||||
notFound();
|
||||
}
|
||||
}
|
||||
|
||||
function TestSessionLoading() {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
|
|
@ -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<TestSessionResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(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 <TestSessionLoading />;
|
||||
}
|
||||
|
||||
if (error || !testSession) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="text-red-500 text-center py-8">
|
||||
{error || 'Test session not found'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<TestSessionDetails session={sessionForUI} />
|
||||
<TestSessionControls session={sessionForUI} />
|
||||
<div className="mt-6">
|
||||
<LiveAudioPlayer
|
||||
testSessionId={testSessionId}
|
||||
sessionStatus={testSession.status as 'pending' | 'running' | 'completed' | 'failed'}
|
||||
autoStart={true}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold mb-4">Conversations</h2>
|
||||
<ConversationsList testSessionId={testSessionId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TestSessionPage() {
|
||||
const backButton = (
|
||||
<Link href="/looptalk">
|
||||
<Button variant="ghost" size="sm">
|
||||
|
|
@ -118,9 +121,7 @@ export default function TestSessionPage({ params }: PageProps) {
|
|||
|
||||
return (
|
||||
<LoopTalkLayout backButton={backButton}>
|
||||
<Suspense fallback={<TestSessionLoading />}>
|
||||
<PageContent params={params} />
|
||||
</Suspense>
|
||||
<TestSessionPageContent />
|
||||
</LoopTalkLayout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { Button } from "@/components/ui/button";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NODE_DOCUMENTATION_URLS } from "@/constants/documentation";
|
||||
import { useAppConfig } from "@/context/AppConfigContext";
|
||||
|
||||
import { NodeContent } from "./common/NodeContent";
|
||||
import { NodeEditDialog } from "./common/NodeEditDialog";
|
||||
|
|
@ -28,7 +27,6 @@ interface TriggerNodeProps extends NodeProps {
|
|||
export const TriggerNode = memo(({ data, selected, id }: TriggerNodeProps) => {
|
||||
const { open, setOpen, handleSaveNodeData, handleDeleteNode } = useNodeHandlers({ id });
|
||||
const { saveWorkflow } = useWorkflow();
|
||||
const { config } = useAppConfig();
|
||||
|
||||
// Form state
|
||||
const [name, setName] = useState(data.name || "API Trigger");
|
||||
|
|
@ -36,8 +34,9 @@ export const TriggerNode = memo(({ data, selected, id }: TriggerNodeProps) => {
|
|||
// Generate trigger_path if not present (should be done on node creation)
|
||||
const [triggerPath] = useState(() => data.trigger_path ?? crypto.randomUUID());
|
||||
|
||||
// Get backend URL from app config (fetched from backend health endpoint)
|
||||
const backendUrl = config?.backendApiEndpoint || "http://localhost:8000";
|
||||
const backendUrl =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ||
|
||||
(typeof window !== 'undefined' ? window.location.origin : '');
|
||||
const endpoint = `${backendUrl}/api/v1/public/agent/${triggerPath}`;
|
||||
|
||||
// Copy state for button feedback
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useAppConfig } from '@/context/AppConfigContext';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import logger from '@/lib/logger';
|
||||
|
||||
|
|
@ -46,7 +45,6 @@ export function LiveAudioPlayer({
|
|||
const nextStartTimeRef = useRef(0);
|
||||
const animationFrameRef = useRef<number | undefined>(undefined);
|
||||
const isConnectingRef = useRef(false);
|
||||
const { config } = useAppConfig();
|
||||
const { user, getAccessToken } = useAuth();
|
||||
|
||||
// Auto-start streaming when session starts
|
||||
|
|
@ -99,8 +97,8 @@ export function LiveAudioPlayer({
|
|||
// Get auth token
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
// Create WebSocket connection
|
||||
const baseUrl = (config?.backendApiEndpoint || 'http://localhost:8000').replace(/^http/, 'ws');
|
||||
const httpBase = process.env.NEXT_PUBLIC_BACKEND_URL || window.location.origin;
|
||||
const baseUrl = httpBase.replace(/^http/, 'ws');
|
||||
const wsUrl = `${baseUrl}/api/v1/looptalk/test-sessions/${testSessionId}/audio-stream?role=${audioRole}&token=${encodeURIComponent(accessToken || '')}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
|
@ -201,7 +199,7 @@ export function LiveAudioPlayer({
|
|||
} finally {
|
||||
isConnectingRef.current = false;
|
||||
}
|
||||
}, [testSessionId, audioRole, user, getAccessToken, volume, monitorAudioLevel, config]); // Removed connectionStatus to avoid loops
|
||||
}, [testSessionId, audioRole, user, getAccessToken, volume, monitorAudioLevel]); // Removed connectionStatus to avoid loops
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
if (wsRef.current) {
|
||||
|
|
|
|||
|
|
@ -30,7 +30,8 @@ export function SimpleAudioPlayer({ testSessionId }: SimpleAudioPlayerProps) {
|
|||
const accessToken = await getAccessToken();
|
||||
|
||||
// Create WebSocket connection - pass token as query param since WebSocket doesn't support headers
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_URL?.replace('http', 'ws') || 'ws://localhost:8000';
|
||||
const httpBase = process.env.NEXT_PUBLIC_BACKEND_URL || window.location.origin;
|
||||
const baseUrl = httpBase.replace(/^http/, 'ws');
|
||||
const wsUrl = `${baseUrl}/api/v1/looptalk/test-sessions/${testSessionId}/audio-stream?role=${audioRole}&token=${encodeURIComponent(accessToken || '')}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
|
|
|||
|
|
@ -2,12 +2,9 @@
|
|||
|
||||
import { createContext, ReactNode, useContext, useEffect, useState } from 'react';
|
||||
|
||||
import { client } from '@/client/client.gen';
|
||||
|
||||
interface AppConfig {
|
||||
uiVersion: string;
|
||||
apiVersion: string;
|
||||
backendApiEndpoint: string | null;
|
||||
deploymentMode: string;
|
||||
authProvider: string;
|
||||
}
|
||||
|
|
@ -20,7 +17,6 @@ interface AppConfigContextType {
|
|||
const defaultConfig: AppConfig = {
|
||||
uiVersion: 'dev',
|
||||
apiVersion: 'unknown',
|
||||
backendApiEndpoint: null,
|
||||
deploymentMode: 'oss',
|
||||
authProvider: 'local',
|
||||
};
|
||||
|
|
@ -38,15 +34,9 @@ export function AppConfigProvider({ children }: { children: ReactNode }) {
|
|||
fetch('/api/config/version')
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
// Use clientApiBaseUrl (filtered for browser-reachable URLs)
|
||||
// to configure the API client; keep backendApiEndpoint for display
|
||||
if (data.clientApiBaseUrl) {
|
||||
client.setConfig({ baseUrl: data.clientApiBaseUrl });
|
||||
}
|
||||
setConfig({
|
||||
uiVersion: data.ui || 'dev',
|
||||
apiVersion: data.api || 'unknown',
|
||||
backendApiEndpoint: data.backendApiEndpoint || null,
|
||||
deploymentMode: data.deploymentMode || 'oss',
|
||||
authProvider: data.authProvider || 'local',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,12 +7,9 @@ export const createClientConfig: CreateClientConfig = (config) => {
|
|||
let baseUrl: string;
|
||||
|
||||
if (isServer) {
|
||||
// for server-side rendering, still use environment variable as fallback
|
||||
baseUrl = process.env.BACKEND_URL || 'http://api:8000';
|
||||
} else {
|
||||
// Client-side API calls are proxied through Next.js rewrites.
|
||||
// AppConfigContext may update this later with the fetched backend URL.
|
||||
baseUrl = window.location.origin;
|
||||
baseUrl = process.env.NEXT_PUBLIC_BACKEND_URL || window.location.origin;
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue