mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-28 08:49:42 +02:00
Initial Commit 🚀 🚀
This commit is contained in:
commit
4f2a629340
444 changed files with 76863 additions and 0 deletions
20
ui/src/app/after-sign-in/page.tsx
Normal file
20
ui/src/app/after-sign-in/page.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerAuthProvider, getServerUser } from "@/lib/auth/server";
|
||||
import { getRedirectUrl } from "@/lib/utils";
|
||||
|
||||
export default async function AfterSignInPage() {
|
||||
const authProvider = getServerAuthProvider();
|
||||
const user = await getServerUser();
|
||||
|
||||
if (authProvider === 'stack' && user && 'getAuthJson' in user) {
|
||||
const token = await user.getAuthJson();
|
||||
const permissions = 'listPermissions' in user && 'selectedTeam' in user
|
||||
? await user.listPermissions(user.selectedTeam!) ?? []
|
||||
: [];
|
||||
const redirectUrl = await getRedirectUrl(token?.accessToken ?? "", permissions);
|
||||
redirect(redirectUrl);
|
||||
}
|
||||
// For local provider or if user is not available, redirect to create-workflow
|
||||
redirect('/create-workflow');
|
||||
}
|
||||
14
ui/src/app/api-keys/layout.tsx
Normal file
14
ui/src/app/api-keys/layout.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import BaseHeader from "@/components/header/BaseHeader";
|
||||
|
||||
export default function APIKeysLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<BaseHeader/>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
677
ui/src/app/api-keys/page.tsx
Normal file
677
ui/src/app/api-keys/page.tsx
Normal file
|
|
@ -0,0 +1,677 @@
|
|||
"use client";
|
||||
|
||||
import { Copy, Eye, EyeOff, Key, Plus, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
archiveApiKeyApiV1UserApiKeysApiKeyIdDelete,
|
||||
archiveServiceKeyApiV1UserServiceKeysServiceKeyIdDelete,
|
||||
createApiKeyApiV1UserApiKeysPost,
|
||||
createServiceKeyApiV1UserServiceKeysPost,
|
||||
getApiKeysApiV1UserApiKeysGet,
|
||||
getServiceKeysApiV1UserServiceKeysGet,
|
||||
reactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePut
|
||||
} from '@/client/sdk.gen';
|
||||
import type { ApiKeyResponse, CreateApiKeyResponse, CreateServiceKeyResponse,ServiceKeyResponse } from '@/client/types.gen';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
|
||||
export default function APIKeysPage() {
|
||||
const { user, getAccessToken, redirectToLogin, loading } = useAuth();
|
||||
|
||||
const [apiKeys, setApiKeys] = useState<ApiKeyResponse[]>([]);
|
||||
const [serviceKeys, setServiceKeys] = useState<ServiceKeyResponse[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isServiceKeysLoading, setIsServiceKeysLoading] = useState(true);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [showServiceArchived, setShowServiceArchived] = useState(false);
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
const [isCreateServiceDialogOpen, setIsCreateServiceDialogOpen] = useState(false);
|
||||
const [newKeyName, setNewKeyName] = useState('');
|
||||
const [newServiceKeyName, setNewServiceKeyName] = useState('');
|
||||
const [createdKey, setCreatedKey] = useState<CreateApiKeyResponse | null>(null);
|
||||
const [createdServiceKey, setCreatedServiceKey] = useState<CreateServiceKeyResponse | null>(null);
|
||||
const [showCreatedKeyDialog, setShowCreatedKeyDialog] = useState(false);
|
||||
const [showCreatedServiceKeyDialog, setShowCreatedServiceKeyDialog] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Redirect if not authenticated
|
||||
useEffect(() => {
|
||||
if (!loading && !user) {
|
||||
redirectToLogin();
|
||||
}
|
||||
}, [loading, user, redirectToLogin]);
|
||||
|
||||
const fetchApiKeys = useCallback(async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
const response = await getApiKeysApiV1UserApiKeysGet({
|
||||
query: {
|
||||
|
||||
include_archived: showArchived
|
||||
|
||||
},
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
setApiKeys(response.data);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to fetch API keys');
|
||||
console.error('Error fetching API keys:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [user, getAccessToken, showArchived]);
|
||||
|
||||
const fetchServiceKeys = useCallback(async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
setIsServiceKeysLoading(true);
|
||||
setError(null);
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
const response = await getServiceKeysApiV1UserServiceKeysGet({
|
||||
query: {
|
||||
include_archived: showServiceArchived
|
||||
},
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
setServiceKeys(response.data);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to fetch service keys');
|
||||
console.error('Error fetching service keys:', err);
|
||||
} finally {
|
||||
setIsServiceKeysLoading(false);
|
||||
}
|
||||
}, [user, getAccessToken, showServiceArchived]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchApiKeys();
|
||||
}, [fetchApiKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchServiceKeys();
|
||||
}, [fetchServiceKeys]);
|
||||
|
||||
const handleCreateKey = async () => {
|
||||
if (!newKeyName.trim()) {
|
||||
setError('Please enter a name for the API key');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
const response = await createApiKeyApiV1UserApiKeysPost({
|
||||
body: {
|
||||
name: newKeyName
|
||||
},
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
setCreatedKey(response.data);
|
||||
setIsCreateDialogOpen(false);
|
||||
setShowCreatedKeyDialog(true);
|
||||
setNewKeyName('');
|
||||
fetchApiKeys();
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to create API key');
|
||||
console.error('Error creating API key:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateServiceKey = async () => {
|
||||
if (!newServiceKeyName.trim()) {
|
||||
setError('Please enter a name for the service key');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
const response = await createServiceKeyApiV1UserServiceKeysPost({
|
||||
body: {
|
||||
name: newServiceKeyName,
|
||||
expires_in_days: 90
|
||||
},
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
setCreatedServiceKey(response.data);
|
||||
setIsCreateServiceDialogOpen(false);
|
||||
setShowCreatedServiceKeyDialog(true);
|
||||
setNewServiceKeyName('');
|
||||
fetchServiceKeys();
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to create service key');
|
||||
console.error('Error creating service key:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchiveKey = async (keyId: number) => {
|
||||
try {
|
||||
setError(null);
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
await archiveApiKeyApiV1UserApiKeysApiKeyIdDelete({
|
||||
path: {
|
||||
api_key_id: keyId
|
||||
},
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
}
|
||||
});
|
||||
|
||||
fetchApiKeys();
|
||||
} catch (err) {
|
||||
setError('Failed to archive API key');
|
||||
console.error('Error archiving API key:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchiveServiceKey = async (keyId: string) => {
|
||||
try {
|
||||
setError(null);
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
await archiveServiceKeyApiV1UserServiceKeysServiceKeyIdDelete({
|
||||
path: {
|
||||
service_key_id: keyId
|
||||
},
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
}
|
||||
});
|
||||
|
||||
fetchServiceKeys();
|
||||
} catch (err) {
|
||||
setError('Failed to archive service key');
|
||||
console.error('Error archiving service key:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReactivateKey = async (keyId: number) => {
|
||||
try {
|
||||
setError(null);
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
await reactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePut({
|
||||
path: {
|
||||
|
||||
api_key_id: keyId
|
||||
|
||||
},
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
}
|
||||
});
|
||||
|
||||
fetchApiKeys();
|
||||
} catch (err) {
|
||||
setError('Failed to reactivate API key');
|
||||
console.error('Error reactivating API key:', err);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy to clipboard:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return 'Never';
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
// Don't render content until auth is loaded
|
||||
if (loading || !user) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-12 w-64" />
|
||||
<Skeleton className="h-64 w-96" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Developer Portal</h1>
|
||||
<p className="text-gray-600">Manage your API keys to access Dograh services programmatically</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<CardTitle>API Keys</CardTitle>
|
||||
<CardDescription>
|
||||
Create and manage API keys for your organization
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowArchived(!showArchived)}
|
||||
>
|
||||
{showArchived ? <Eye className="w-4 h-4 mr-2" /> : <EyeOff className="w-4 h-4 mr-2" />}
|
||||
{showArchived ? 'Hide' : 'Show'} Archived
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setIsCreateDialogOpen(true)}
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create New Key
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : apiKeys.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Key className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-600 mb-4">No API keys found</p>
|
||||
<Button onClick={() => setIsCreateDialogOpen(true)}>
|
||||
Create Your First API Key
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{apiKeys.map((key) => (
|
||||
<div
|
||||
key={key.id}
|
||||
className={`flex items-center justify-between p-4 border rounded-lg ${
|
||||
key.archived_at ? 'bg-gray-50 opacity-60' : 'bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-gray-900">{key.name}</span>
|
||||
{key.archived_at ? (
|
||||
<Badge variant="secondary">Archived</Badge>
|
||||
) : key.is_active ? (
|
||||
<Badge variant="default">Active</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive">Inactive</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<span className="font-mono bg-gray-100 px-2 py-1 rounded">{key.key_prefix}...</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
(Full key hidden for security)
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
Created: {formatDate(key.created_at)} •
|
||||
Last used: {formatDate(key.last_used_at ?? null)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{key.archived_at ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleReactivateKey(key.id)}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-1" />
|
||||
Reactivate
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleArchiveKey(key.id)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Dograh Service Keys Section */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<CardTitle>Dograh Service Keys</CardTitle>
|
||||
<CardDescription>
|
||||
Manage service keys for accessing Dograh AI services (LLM, TTS, STT)
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowServiceArchived(!showServiceArchived)}
|
||||
>
|
||||
{showServiceArchived ? <Eye className="w-4 h-4 mr-2" /> : <EyeOff className="w-4 h-4 mr-2" />}
|
||||
{showServiceArchived ? 'Hide' : 'Show'} Archived
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setIsCreateServiceDialogOpen(true)}
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Service Key
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isServiceKeysLoading ? (
|
||||
<div className="space-y-4">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : serviceKeys.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Key className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-600 mb-4">No service keys found</p>
|
||||
<Button onClick={() => setIsCreateServiceDialogOpen(true)}>
|
||||
Create Your First Service Key
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{serviceKeys.map((key) => (
|
||||
<div
|
||||
key={key.id}
|
||||
className={`flex items-center justify-between p-4 border rounded-lg ${
|
||||
key.archived_at ? 'bg-gray-50 opacity-60' : 'bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-gray-900">{key.name}</span>
|
||||
{key.archived_at ? (
|
||||
<Badge variant="secondary">Archived</Badge>
|
||||
) : key.is_active ? (
|
||||
<Badge variant="default">Active</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive">Inactive</Badge>
|
||||
)}
|
||||
{key.expires_at && new Date(key.expires_at) > new Date() && (
|
||||
<Badge variant="outline">
|
||||
Expires: {formatDate(key.expires_at)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<span className="font-mono bg-gray-100 px-2 py-1 rounded">{key.key_prefix}...</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
(Full key hidden for security)
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
Created: {formatDate(key.created_at)} •
|
||||
Last used: {formatDate(key.last_used_at ?? null)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{!key.archived_at && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleArchiveServiceKey(String(key.id))}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<p className="text-sm text-yellow-800">
|
||||
<strong>Important:</strong> Keep your API keys secure. Never share them publicly or commit them to version control.
|
||||
API keys provide full access to your organization's resources.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create API Key Dialog */}
|
||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New API Key</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter a descriptive name for your API key to help you identify it later.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">Key Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
placeholder="e.g., Production Server, Development Environment"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreateKey}>
|
||||
Create Key
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Show Created Key Dialog */}
|
||||
<Dialog open={showCreatedKeyDialog} onOpenChange={setShowCreatedKeyDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>API Key Created Successfully</DialogTitle>
|
||||
<DialogDescription>
|
||||
Make sure to copy your API key now. You won't be able to see it again!
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{createdKey && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-gray-100 rounded-lg">
|
||||
<p className="text-sm text-gray-600 mb-2">Your API Key:</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 p-2 bg-white rounded text-sm font-mono break-all">
|
||||
{createdKey.api_key}
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => copyToClipboard(createdKey.api_key)}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<p className="text-sm text-yellow-800">
|
||||
Store this key securely. It will only be shown once and cannot be retrieved later.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button onClick={() => {
|
||||
setShowCreatedKeyDialog(false);
|
||||
setCreatedKey(null);
|
||||
}}>
|
||||
Done
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Create Service Key Dialog */}
|
||||
<Dialog open={isCreateServiceDialogOpen} onOpenChange={setIsCreateServiceDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Service Key</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a service key to access Dograh AI services (LLM, TTS, STT)
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="service-name">Service Key Name</Label>
|
||||
<Input
|
||||
id="service-name"
|
||||
value={newServiceKeyName}
|
||||
onChange={(e) => setNewServiceKeyName(e.target.value)}
|
||||
placeholder="e.g., Production AI Services, Development LLM Access"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsCreateServiceDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreateServiceKey}>
|
||||
Create Service Key
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Show Created Service Key Dialog */}
|
||||
<Dialog open={showCreatedServiceKeyDialog} onOpenChange={setShowCreatedServiceKeyDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Service Key Created Successfully</DialogTitle>
|
||||
<DialogDescription>
|
||||
Make sure to copy your service key now. You won't be able to see it again!
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{createdServiceKey && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-gray-100 rounded-lg">
|
||||
<p className="text-sm text-gray-600 mb-2">Your Service Key:</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 p-2 bg-white rounded text-sm font-mono break-all">
|
||||
{createdServiceKey.service_key}
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => copyToClipboard(createdServiceKey.service_key)}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<p className="text-sm text-blue-800">
|
||||
This key provides access to Dograh AI services including LLM, Text-to-Speech, and Speech-to-Text.
|
||||
{createdServiceKey.expires_at && (
|
||||
<span className="block mt-1">
|
||||
Expires on: {formatDate(createdServiceKey.expires_at)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<p className="text-sm text-yellow-800">
|
||||
Store this key securely. It will only be shown once and cannot be retrieved later.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button onClick={() => {
|
||||
setShowCreatedServiceKeyDialog(false);
|
||||
setCreatedServiceKey(null);
|
||||
}}>
|
||||
Done
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
ui/src/app/api/auth/oss/route.ts
Normal file
60
ui/src/app/api/auth/oss/route.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
Helps provide authentication token to LocalAuthService once its loaded
|
||||
in the browser
|
||||
*/
|
||||
import { cookies } from 'next/headers';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
const OSS_TOKEN_COOKIE = 'dograh_oss_token';
|
||||
const OSS_USER_COOKIE = 'dograh_oss_user';
|
||||
|
||||
function generateOSSToken(): string {
|
||||
return `oss_${Date.now()}_${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const authProvider = process.env.NEXT_PUBLIC_AUTH_PROVIDER || 'stack';
|
||||
|
||||
// Only handle OSS mode
|
||||
if (authProvider !== 'local') {
|
||||
return NextResponse.json({ error: 'Not in OSS mode' }, { status: 400 });
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
let token = cookieStore.get(OSS_TOKEN_COOKIE)?.value;
|
||||
let user = cookieStore.get(OSS_USER_COOKIE)?.value;
|
||||
|
||||
// If no token exists, create one
|
||||
if (!token) {
|
||||
token = generateOSSToken();
|
||||
user = JSON.stringify({
|
||||
id: token,
|
||||
name: 'Local User',
|
||||
provider: 'local',
|
||||
organizationId: `org_${token}`,
|
||||
});
|
||||
|
||||
// Set cookies
|
||||
cookieStore.set(OSS_TOKEN_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 30, // 30 days
|
||||
path: '/',
|
||||
});
|
||||
|
||||
cookieStore.set(OSS_USER_COOKIE, user, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 30, // 30 days
|
||||
path: '/',
|
||||
});
|
||||
}
|
||||
|
||||
// Return the auth info as JSON (safe to expose to client)
|
||||
return NextResponse.json({
|
||||
token,
|
||||
user: JSON.parse(user!),
|
||||
});
|
||||
}
|
||||
14
ui/src/app/automation/layout.tsx
Normal file
14
ui/src/app/automation/layout.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import BaseHeader from "@/components/header/BaseHeader"
|
||||
|
||||
export default function CampaignsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<BaseHeader />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
35
ui/src/app/automation/page.tsx
Normal file
35
ui/src/app/automation/page.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"use client";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
export default function AutomationPage() {
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-73px)] bg-gray-50 p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Automation</h1>
|
||||
<p className="text-gray-600">Automate your workflows and processes</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Coming Soon</CardTitle>
|
||||
<CardDescription>
|
||||
Automation features are currently under development
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 text-lg mb-4">
|
||||
We're working on powerful automation features to help you streamline your workflows.
|
||||
</p>
|
||||
<p className="text-gray-500">
|
||||
Check back soon for updates!
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
237
ui/src/app/campaigns/GoogleSheetSelector.tsx
Normal file
237
ui/src/app/campaigns/GoogleSheetSelector.tsx
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { getIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGet, getIntegrationsApiV1IntegrationGet } from '@/client/sdk.gen';
|
||||
import type { IntegrationResponse } from '@/client/types.gen';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import logger from '@/lib/logger';
|
||||
|
||||
interface GoogleSheetSelectorProps {
|
||||
accessToken: string;
|
||||
onSheetSelected: (sheetUrl: string, sheetName: string) => void;
|
||||
selectedSheetUrl?: string;
|
||||
}
|
||||
|
||||
interface PickerBuilder {
|
||||
addView: (viewId: string) => PickerBuilder;
|
||||
setOAuthToken: (token: string) => PickerBuilder;
|
||||
setDeveloperKey: (key: string) => PickerBuilder;
|
||||
setCallback: (callback: (data: { action: string; docs?: Array<{ id: string; name: string; url: string }> }) => void) => PickerBuilder;
|
||||
setTitle: (title: string) => PickerBuilder;
|
||||
build: () => { setVisible: (visible: boolean) => void };
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
gapi: {
|
||||
load: (library: string, callback: () => void) => void;
|
||||
};
|
||||
google: {
|
||||
picker: {
|
||||
PickerBuilder: new () => PickerBuilder;
|
||||
ViewId: {
|
||||
SPREADSHEETS: string;
|
||||
};
|
||||
Action: {
|
||||
PICKED: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Google API configuration
|
||||
const GOOGLE_API_KEY = process.env.NEXT_PUBLIC_GOOGLE_API_KEY || '';
|
||||
|
||||
export default function GoogleSheetSelector({ accessToken, onSheetSelected, selectedSheetUrl }: GoogleSheetSelectorProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pickerApiLoaded, setPickerApiLoaded] = useState(false);
|
||||
const [googleIntegration, setGoogleIntegration] = useState<IntegrationResponse | null>(null);
|
||||
const [selectedSheetName, setSelectedSheetName] = useState<string>('');
|
||||
const [checkingIntegration, setCheckingIntegration] = useState(true);
|
||||
|
||||
// Load Google Picker API
|
||||
useEffect(() => {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://apis.google.com/js/api.js';
|
||||
script.onload = () => {
|
||||
window.gapi.load('picker', () => {
|
||||
setPickerApiLoaded(true);
|
||||
logger.info('Google Picker API loaded');
|
||||
});
|
||||
};
|
||||
document.body.appendChild(script);
|
||||
|
||||
return () => {
|
||||
if (document.body.contains(script)) {
|
||||
document.body.removeChild(script);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Check for Google Sheet integration
|
||||
useEffect(() => {
|
||||
const checkGoogleIntegration = async () => {
|
||||
if (!accessToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getIntegrationsApiV1IntegrationGet({
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
const integrations = Array.isArray(response.data) ? response.data : [response.data];
|
||||
const googleSheet = integrations.find(i => i.provider === 'google-sheet');
|
||||
setGoogleIntegration(googleSheet || null);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to check Google integration:', error);
|
||||
} finally {
|
||||
setCheckingIntegration(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkGoogleIntegration();
|
||||
}, [accessToken]);
|
||||
|
||||
const fetchGoogleAccessToken = async () => {
|
||||
if (!googleIntegration) return null;
|
||||
|
||||
try {
|
||||
const response = await getIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGet({
|
||||
path: {
|
||||
integration_id: googleIntegration.id,
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data?.access_token) {
|
||||
return response.data.access_token;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch Google access token:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const openGooglePicker = async () => {
|
||||
if (!pickerApiLoaded) {
|
||||
toast.error('Google Picker is still loading. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!GOOGLE_API_KEY) {
|
||||
toast.error('Google API Key is not configured.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!googleIntegration) {
|
||||
toast.error('Please connect Google Sheets in the Integrations page first.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const token = await fetchGoogleAccessToken();
|
||||
if (!token) {
|
||||
toast.error('Failed to get Google access token. Please re-authorize in Integrations.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const picker = new window.google.picker.PickerBuilder()
|
||||
.addView(window.google.picker.ViewId.SPREADSHEETS)
|
||||
.setOAuthToken(token)
|
||||
.setDeveloperKey(GOOGLE_API_KEY)
|
||||
.setCallback((data: { action: string; docs?: Array<{ id: string; name: string; url: string }> }) => {
|
||||
if (data.action === window.google.picker.Action.PICKED && data.docs && data.docs.length > 0) {
|
||||
const doc = data.docs[0];
|
||||
setSelectedSheetName(doc.name);
|
||||
onSheetSelected(doc.url, doc.name);
|
||||
toast.success(`Selected: ${doc.name}`);
|
||||
}
|
||||
setLoading(false);
|
||||
})
|
||||
.setTitle('Select a Google Sheet for your campaign')
|
||||
.build();
|
||||
|
||||
picker.setVisible(true);
|
||||
} catch (error) {
|
||||
toast.error('Error opening Google Picker');
|
||||
logger.error('Error opening Google Picker:', error);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (checkingIntegration) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>Google Sheet</Label>
|
||||
<div className="text-sm text-gray-500">Checking Google integration...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!googleIntegration) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>Google Sheet</Label>
|
||||
<div className="p-4 border border-amber-200 bg-amber-50 rounded-md">
|
||||
<p className="text-sm text-amber-800 mb-2">
|
||||
Google Sheets integration not found
|
||||
</p>
|
||||
<p className="text-sm text-amber-700">
|
||||
Please go to the{' '}
|
||||
<a href="/integrations" className="text-amber-900 underline font-medium">
|
||||
Integrations page
|
||||
</a>
|
||||
{' '}and connect your Google account first.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>Google Sheet</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={openGooglePicker}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Opening...' : 'Select Google Sheet'}
|
||||
</Button>
|
||||
{selectedSheetUrl && (
|
||||
<div className="flex-1 text-sm">
|
||||
<span className="text-gray-600">Selected: </span>
|
||||
<a
|
||||
href={selectedSheetUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
{selectedSheetName || selectedSheetUrl}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
Select a Google Sheet from your connected Google account
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
463
ui/src/app/campaigns/[campaignId]/page.tsx
Normal file
463
ui/src/app/campaigns/[campaignId]/page.tsx
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowLeft, Pause, Play, RefreshCw } from 'lucide-react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
getCampaignApiV1CampaignCampaignIdGet,
|
||||
getCampaignRunsApiV1CampaignCampaignIdRunsGet,
|
||||
pauseCampaignApiV1CampaignCampaignIdPausePost,
|
||||
resumeCampaignApiV1CampaignCampaignIdResumePost,
|
||||
startCampaignApiV1CampaignCampaignIdStartPost} from '@/client/sdk.gen';
|
||||
import type { CampaignResponse, WorkflowRunResponse } from '@/client/types.gen';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
|
||||
export default function CampaignDetailPage() {
|
||||
const { user, getAccessToken, redirectToLogin, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const campaignId = parseInt(params.campaignId as string);
|
||||
|
||||
// Redirect if not authenticated
|
||||
useEffect(() => {
|
||||
if (!loading && !user) {
|
||||
redirectToLogin();
|
||||
}
|
||||
}, [loading, user, redirectToLogin]);
|
||||
|
||||
// Campaign state
|
||||
const [campaign, setCampaign] = useState<CampaignResponse | null>(null);
|
||||
const [isLoadingCampaign, setIsLoadingCampaign] = useState(true);
|
||||
|
||||
// Runs state
|
||||
const [runs, setRuns] = useState<WorkflowRunResponse[]>([]);
|
||||
const [isLoadingRuns, setIsLoadingRuns] = useState(false);
|
||||
|
||||
// Action state
|
||||
const [isExecutingAction, setIsExecutingAction] = useState(false);
|
||||
|
||||
// Fetch campaign details
|
||||
const fetchCampaign = useCallback(async () => {
|
||||
if (!user) return;
|
||||
setIsLoadingCampaign(true);
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
const response = await getCampaignApiV1CampaignCampaignIdGet({
|
||||
path: {
|
||||
campaign_id: campaignId,
|
||||
},
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
setCampaign(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch campaign:', error);
|
||||
toast.error('Failed to load campaign details');
|
||||
} finally {
|
||||
setIsLoadingCampaign(false);
|
||||
}
|
||||
}, [user, getAccessToken, campaignId]);
|
||||
|
||||
// Fetch campaign runs
|
||||
const fetchCampaignRuns = useCallback(async () => {
|
||||
if (!user) return;
|
||||
setIsLoadingRuns(true);
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
const response = await getCampaignRunsApiV1CampaignCampaignIdRunsGet({
|
||||
path: {
|
||||
campaign_id: campaignId,
|
||||
},
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
setRuns(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch campaign runs:', error);
|
||||
} finally {
|
||||
setIsLoadingRuns(false);
|
||||
}
|
||||
}, [user, getAccessToken, campaignId]);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
fetchCampaign();
|
||||
fetchCampaignRuns();
|
||||
}, [fetchCampaign, fetchCampaignRuns]);
|
||||
|
||||
// Handle back navigation
|
||||
const handleBack = () => {
|
||||
router.push('/campaigns');
|
||||
};
|
||||
|
||||
// Handle workflow link click
|
||||
const handleWorkflowClick = () => {
|
||||
if (campaign) {
|
||||
router.push(`/workflow/${campaign.workflow_id}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle run click
|
||||
const handleRunClick = (runId: number) => {
|
||||
if (campaign) {
|
||||
router.push(`/workflow/${campaign.workflow_id}/run/${runId}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle start campaign
|
||||
const handleStart = async () => {
|
||||
if (!user) return;
|
||||
setIsExecutingAction(true);
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
const response = await startCampaignApiV1CampaignCampaignIdStartPost({
|
||||
path: {
|
||||
campaign_id: campaignId,
|
||||
},
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
setCampaign(response.data);
|
||||
toast.success('Campaign started');
|
||||
} else if (response.error) {
|
||||
// Extract error message from response
|
||||
let errorMsg = 'Failed to start campaign';
|
||||
if (typeof response.error === 'string') {
|
||||
errorMsg = response.error;
|
||||
} else if (response.error && typeof response.error === 'object') {
|
||||
errorMsg = (response.error as unknown as { detail?: string }).detail || JSON.stringify(response.error);
|
||||
}
|
||||
toast.error(errorMsg);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to start campaign:', error);
|
||||
toast.error('Failed to start campaign');
|
||||
} finally {
|
||||
setIsExecutingAction(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle resume campaign
|
||||
const handleResume = async () => {
|
||||
if (!user) return;
|
||||
setIsExecutingAction(true);
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
const response = await resumeCampaignApiV1CampaignCampaignIdResumePost({
|
||||
path: {
|
||||
campaign_id: campaignId,
|
||||
},
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
setCampaign(response.data);
|
||||
toast.success('Campaign resumed');
|
||||
} else if (response.error) {
|
||||
// Extract error message from response
|
||||
let errorMsg = 'Failed to resume campaign';
|
||||
if (typeof response.error === 'string') {
|
||||
errorMsg = response.error;
|
||||
} else if (response.error && typeof response.error === 'object') {
|
||||
errorMsg = (response.error as unknown as { detail?: string }).detail || JSON.stringify(response.error);
|
||||
}
|
||||
toast.error(errorMsg);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to resume campaign:', error);
|
||||
toast.error('Failed to resume campaign');
|
||||
} finally {
|
||||
setIsExecutingAction(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle pause campaign
|
||||
const handlePause = async () => {
|
||||
if (!user) return;
|
||||
setIsExecutingAction(true);
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
const response = await pauseCampaignApiV1CampaignCampaignIdPausePost({
|
||||
path: {
|
||||
campaign_id: campaignId,
|
||||
},
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
setCampaign(response.data);
|
||||
toast.success('Campaign paused');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to pause campaign:', error);
|
||||
toast.error('Failed to pause campaign');
|
||||
} finally {
|
||||
setIsExecutingAction(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString();
|
||||
};
|
||||
|
||||
const formatDateTime = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
// Get badge variant for state
|
||||
const getStateBadgeVariant = (state: string) => {
|
||||
switch (state) {
|
||||
case 'created':
|
||||
return 'secondary';
|
||||
case 'running':
|
||||
return 'default';
|
||||
case 'paused':
|
||||
return 'outline';
|
||||
case 'completed':
|
||||
return 'secondary';
|
||||
case 'failed':
|
||||
return 'destructive';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
};
|
||||
|
||||
// Render action button based on state
|
||||
const renderActionButton = () => {
|
||||
if (!campaign || isExecutingAction) return null;
|
||||
|
||||
switch (campaign.state) {
|
||||
case 'created':
|
||||
return (
|
||||
<Button onClick={handleStart} disabled={isExecutingAction}>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
Start Campaign
|
||||
</Button>
|
||||
);
|
||||
case 'running':
|
||||
return (
|
||||
<Button onClick={handlePause} disabled={isExecutingAction}>
|
||||
<Pause className="h-4 w-4 mr-2" />
|
||||
Pause Campaign
|
||||
</Button>
|
||||
);
|
||||
case 'paused':
|
||||
return (
|
||||
<Button onClick={handleResume} disabled={isExecutingAction}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Resume Campaign
|
||||
</Button>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingCampaign) {
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-73px)] bg-gray-50 p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||
<div className="h-64 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!campaign) {
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-73px)] bg-gray-50 p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<p className="text-center text-gray-500">Campaign not found</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-73px)] bg-gray-50 p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleBack}
|
||||
className="mb-4"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Campaigns
|
||||
</Button>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">{campaign.name}</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<Badge variant={getStateBadgeVariant(campaign.state)}>
|
||||
{campaign.state}
|
||||
</Badge>
|
||||
<span className="text-gray-600">
|
||||
Created {formatDate(campaign.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{renderActionButton()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Campaign Details */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Campaign Details</CardTitle>
|
||||
<CardDescription>
|
||||
Configuration and source information
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Workflow</dt>
|
||||
<dd className="mt-1">
|
||||
<button
|
||||
onClick={handleWorkflowClick}
|
||||
className="text-blue-600 hover:text-blue-800 hover:underline"
|
||||
>
|
||||
{campaign.workflow_name}
|
||||
</button>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Source Type</dt>
|
||||
<dd className="mt-1 capitalize">{campaign.source_type.replace('-', ' ')}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Source Sheet</dt>
|
||||
<dd className="mt-1">
|
||||
<a
|
||||
href={campaign.source_id}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 hover:underline text-sm break-all"
|
||||
>
|
||||
{campaign.source_id}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">State</dt>
|
||||
<dd className="mt-1 capitalize">{campaign.state}</dd>
|
||||
</div>
|
||||
{campaign.started_at && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Started At</dt>
|
||||
<dd className="mt-1">{formatDateTime(campaign.started_at)}</dd>
|
||||
</div>
|
||||
)}
|
||||
{campaign.completed_at && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Completed At</dt>
|
||||
<dd className="mt-1">{formatDateTime(campaign.completed_at)}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Workflow Runs */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Workflow Runs</CardTitle>
|
||||
<CardDescription>
|
||||
Executions triggered by this campaign
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingRuns ? (
|
||||
<div className="animate-pulse space-y-3">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="h-12 bg-gray-200 rounded"></div>
|
||||
))}
|
||||
</div>
|
||||
) : runs.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Run ID</TableHead>
|
||||
<TableHead>State</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="text-right">Action</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{runs.map((run) => (
|
||||
<TableRow
|
||||
key={run.id}
|
||||
className="cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => handleRunClick(run.id)}
|
||||
>
|
||||
<TableCell className="font-mono text-sm">#{run.id}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={run.state === 'completed' ? 'secondary' : 'default'}>
|
||||
{run.state}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{formatDateTime(run.created_at)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRunClick(run.id);
|
||||
}}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center py-8 text-gray-500">
|
||||
{campaign.state === 'created'
|
||||
? 'No runs yet. Start the campaign to begin execution.'
|
||||
: 'No workflow runs found for this campaign.'}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
ui/src/app/campaigns/layout.tsx
Normal file
14
ui/src/app/campaigns/layout.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import BaseHeader from "@/components/header/BaseHeader"
|
||||
|
||||
export default function CampaignsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<BaseHeader />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
227
ui/src/app/campaigns/new/page.tsx
Normal file
227
ui/src/app/campaigns/new/page.tsx
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { createCampaignApiV1CampaignCreatePost, getWorkflowsSummaryApiV1WorkflowSummaryGet } from '@/client/sdk.gen';
|
||||
import type { WorkflowSummaryResponse } from '@/client/types.gen';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
|
||||
import GoogleSheetSelector from '../GoogleSheetSelector';
|
||||
|
||||
export default function NewCampaignPage() {
|
||||
const { user, getAccessToken, redirectToLogin, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
// Form state
|
||||
const [campaignName, setCampaignName] = useState('');
|
||||
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string>('');
|
||||
const [selectedSheetUrl, setSelectedSheetUrl] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [userAccessToken, setUserAccessToken] = useState<string>('');
|
||||
|
||||
// Workflows state
|
||||
const [workflows, setWorkflows] = useState<WorkflowSummaryResponse[]>([]);
|
||||
const [isLoadingWorkflows, setIsLoadingWorkflows] = useState(true);
|
||||
|
||||
// Redirect if not authenticated
|
||||
useEffect(() => {
|
||||
if (!loading && !user) {
|
||||
redirectToLogin();
|
||||
}
|
||||
}, [loading, user, redirectToLogin]);
|
||||
|
||||
// Fetch workflows
|
||||
const fetchWorkflows = useCallback(async () => {
|
||||
if (!user) return;
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
setUserAccessToken(accessToken);
|
||||
const response = await getWorkflowsSummaryApiV1WorkflowSummaryGet({
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
setWorkflows(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch workflows:', error);
|
||||
toast.error('Failed to load workflows');
|
||||
} finally {
|
||||
setIsLoadingWorkflows(false);
|
||||
}
|
||||
}, [user, getAccessToken]);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
fetchWorkflows();
|
||||
}
|
||||
}, [fetchWorkflows, user]);
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!campaignName || !selectedWorkflowId || !selectedSheetUrl) {
|
||||
toast.error('Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
const response = await createCampaignApiV1CampaignCreatePost({
|
||||
body: {
|
||||
name: campaignName,
|
||||
workflow_id: parseInt(selectedWorkflowId),
|
||||
source_id: selectedSheetUrl,
|
||||
},
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
toast.success('Campaign created successfully');
|
||||
router.push(`/campaigns/${response.data.id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create campaign:', error);
|
||||
toast.error('Failed to create campaign');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle back navigation
|
||||
const handleBack = () => {
|
||||
router.push('/campaigns');
|
||||
};
|
||||
|
||||
// Handle sheet selection
|
||||
const handleSheetSelected = (sheetUrl: string) => {
|
||||
setSelectedSheetUrl(sheetUrl);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-73px)] bg-gray-50 p-6">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleBack}
|
||||
className="mb-4"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Campaigns
|
||||
</Button>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Create New Campaign</h1>
|
||||
<p className="text-gray-600">Set up a new campaign to execute workflows at scale</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Campaign Details</CardTitle>
|
||||
<CardDescription>
|
||||
Configure your campaign settings
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="campaign-name">Campaign Name</Label>
|
||||
<Input
|
||||
id="campaign-name"
|
||||
placeholder="Enter campaign name"
|
||||
value={campaignName}
|
||||
onChange={(e) => setCampaignName(e.target.value)}
|
||||
maxLength={255}
|
||||
required
|
||||
/>
|
||||
<p className="text-sm text-gray-500">
|
||||
Choose a descriptive name for your campaign
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="workflow">Workflow</Label>
|
||||
<Select
|
||||
value={selectedWorkflowId}
|
||||
onValueChange={setSelectedWorkflowId}
|
||||
required
|
||||
>
|
||||
<SelectTrigger id="workflow">
|
||||
<SelectValue placeholder="Select a workflow" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{isLoadingWorkflows ? (
|
||||
<SelectItem value="loading" disabled>
|
||||
Loading workflows...
|
||||
</SelectItem>
|
||||
) : workflows.length === 0 ? (
|
||||
<SelectItem value="none" disabled>
|
||||
No workflows found
|
||||
</SelectItem>
|
||||
) : (
|
||||
workflows.map((workflow) => (
|
||||
<SelectItem
|
||||
key={workflow.id}
|
||||
value={workflow.id.toString()}
|
||||
>
|
||||
{workflow.name}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-gray-500">
|
||||
Select the workflow to execute for each row in the spreadsheet
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<GoogleSheetSelector
|
||||
accessToken={userAccessToken}
|
||||
onSheetSelected={handleSheetSelected}
|
||||
selectedSheetUrl={selectedSheetUrl}
|
||||
/>
|
||||
|
||||
<div className="flex gap-4 pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !campaignName || !selectedWorkflowId || !selectedSheetUrl}
|
||||
>
|
||||
{isSubmitting ? 'Creating...' : 'Create Campaign'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleBack}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
186
ui/src/app/campaigns/page.tsx
Normal file
186
ui/src/app/campaigns/page.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
"use client";
|
||||
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { getCampaignsApiV1CampaignGet } from '@/client/sdk.gen';
|
||||
import type { CampaignsResponse } from '@/client/types.gen';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
|
||||
export default function CampaignsPage() {
|
||||
const { user, getAccessToken, redirectToLogin, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
// Campaigns state
|
||||
const [campaignsData, setCampaignsData] = useState<CampaignsResponse | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Redirect if not authenticated
|
||||
useEffect(() => {
|
||||
if (!loading && !user) {
|
||||
redirectToLogin();
|
||||
}
|
||||
}, [loading, user, redirectToLogin]);
|
||||
|
||||
// Fetch campaigns
|
||||
const fetchCampaigns = useCallback(async () => {
|
||||
if (!user) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
const response = await getCampaignsApiV1CampaignGet({
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
setCampaignsData(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch campaigns:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [user, getAccessToken]);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
fetchCampaigns();
|
||||
}
|
||||
}, [fetchCampaigns, user]);
|
||||
|
||||
// Handle row click to navigate to campaign detail
|
||||
const handleRowClick = (campaignId: number) => {
|
||||
router.push(`/campaigns/${campaignId}`);
|
||||
};
|
||||
|
||||
// Handle create campaign button
|
||||
const handleCreateCampaign = () => {
|
||||
router.push('/campaigns/new');
|
||||
};
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString();
|
||||
};
|
||||
|
||||
// Get badge variant for state
|
||||
const getStateBadgeVariant = (state: string) => {
|
||||
switch (state) {
|
||||
case 'created':
|
||||
return 'secondary';
|
||||
case 'running':
|
||||
return 'default';
|
||||
case 'paused':
|
||||
return 'outline';
|
||||
case 'completed':
|
||||
return 'secondary';
|
||||
case 'failed':
|
||||
return 'destructive';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-73px)] bg-gray-50 p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Campaigns</h1>
|
||||
<p className="text-gray-600">Manage your bulk workflow execution campaigns</p>
|
||||
</div>
|
||||
<Button onClick={handleCreateCampaign}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Campaign
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Campaigns Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>All Campaigns</CardTitle>
|
||||
<CardDescription>
|
||||
View and manage your campaigns
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="animate-pulse space-y-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="h-12 bg-gray-200 rounded"></div>
|
||||
))}
|
||||
</div>
|
||||
) : campaignsData && campaignsData.campaigns.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Workflow</TableHead>
|
||||
<TableHead>State</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="text-right">Action</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{campaignsData.campaigns.map((campaign) => (
|
||||
<TableRow
|
||||
key={campaign.id}
|
||||
className="cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => handleRowClick(campaign.id)}
|
||||
>
|
||||
<TableCell className="font-medium">{campaign.name}</TableCell>
|
||||
<TableCell>{campaign.workflow_name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={getStateBadgeVariant(campaign.state)}>
|
||||
{campaign.state}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(campaign.created_at)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRowClick(campaign.id);
|
||||
}}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-500 mb-4">No campaigns found</p>
|
||||
<Button onClick={handleCreateCampaign} variant="outline">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create your first campaign
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
ui/src/app/create-workflow/layout.tsx
Normal file
14
ui/src/app/create-workflow/layout.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import BaseHeader from "@/components/header/BaseHeader"
|
||||
|
||||
export default function CreateWorkflowLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<BaseHeader />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
170
ui/src/app/create-workflow/page.tsx
Normal file
170
ui/src/app/create-workflow/page.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { createWorkflowFromTemplateApiV1WorkflowCreateTemplatePost } from '@/client/sdk.gen';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import logger from '@/lib/logger';
|
||||
|
||||
export default function CreateWorkflowPage() {
|
||||
const router = useRouter();
|
||||
const { user, getAccessToken } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [callType, setCallType] = useState<'INBOUND' | 'OUTBOUND'>('INBOUND');
|
||||
const [useCase, setUseCase] = useState('');
|
||||
const [activityDescription, setActivityDescription] = useState('');
|
||||
|
||||
const handleCreateWorkflow = async () => {
|
||||
if (!useCase || !activityDescription) {
|
||||
setError('Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
setError('You must be logged in to create a workflow');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
// Call the API to create workflow from template
|
||||
const response = await createWorkflowFromTemplateApiV1WorkflowCreateTemplatePost({
|
||||
body: {
|
||||
call_type: callType,
|
||||
use_case: useCase,
|
||||
activity_description: activityDescription,
|
||||
},
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data?.id) {
|
||||
router.push(`/workflow/${response.data.id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to create workflow. Please try again.');
|
||||
logger.error(`Error creating workflow: ${err}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-64px)] flex items-center justify-center p-4 bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800">
|
||||
<Card className="w-full max-w-4xl shadow-xl border-0 bg-white/95 dark:bg-gray-900/95 backdrop-blur">
|
||||
<CardHeader className="text-center pb-4 pt-6">
|
||||
<h1 className="text-2xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
|
||||
Create Your Voice Agent Workflow
|
||||
</h1>
|
||||
<CardDescription className="text-base mt-2 text-gray-600 dark:text-gray-400">
|
||||
Tell us about your use case and we'll create a customized workflow for you
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 px-6 pb-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
||||
<div className="flex items-center flex-wrap gap-2">
|
||||
<span className="text-base font-medium text-gray-700 dark:text-gray-300">I want to create an</span>
|
||||
<Select value={callType} onValueChange={(value) => setCallType(value as 'INBOUND' | 'OUTBOUND')}>
|
||||
<SelectTrigger className="w-[180px] h-10 text-sm font-semibold border-2 focus:ring-2 focus:ring-blue-500">
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="INBOUND" className="text-sm">
|
||||
<span className="font-medium">📞 INBOUND</span>
|
||||
<span className="text-xs text-gray-500 ml-1">(Users call AI)</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="OUTBOUND" className="text-sm">
|
||||
<span className="font-medium">☎️ OUTBOUND</span>
|
||||
<span className="text-xs text-gray-500 ml-1">(AI calls users)</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-base font-medium text-gray-700 dark:text-gray-300">voice agent</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
||||
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider mb-2">
|
||||
Use Case
|
||||
</label>
|
||||
<div className="flex items-start flex-col gap-2">
|
||||
<span className="text-base font-medium text-gray-700 dark:text-gray-300">Which serves the use case:</span>
|
||||
<Input
|
||||
className="w-full h-10 text-sm px-3 border-2 focus:ring-2 focus:ring-blue-500 transition-all"
|
||||
placeholder="e.g., Lead Qualification, HR Screening, Customer Support"
|
||||
value={useCase}
|
||||
onChange={(e) => setUseCase(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
||||
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider mb-2">
|
||||
Activity Description
|
||||
</label>
|
||||
<div className="flex items-start flex-col gap-2">
|
||||
<span className="text-base font-medium text-gray-700 dark:text-gray-300">Which can:</span>
|
||||
<textarea
|
||||
className="w-full min-h-[80px] text-sm px-3 py-2 border-2 rounded-md focus:ring-2 focus:ring-blue-500 transition-all resize-none"
|
||||
placeholder="Describe what your voice agent will do (e.g., Qualify leads for real estate, Screen candidates for roles, Handle customer support)"
|
||||
value={activityDescription}
|
||||
onChange={(e) => setActivityDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-red-600 bg-red-50 dark:bg-red-900/20 p-3 rounded-lg border border-red-200 dark:border-red-800 flex items-center gap-2">
|
||||
<svg className="w-5 h-5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
onClick={handleCreateWorkflow}
|
||||
disabled={isLoading || !useCase || !activityDescription}
|
||||
className="w-full h-12 text-base font-semibold bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 transition-all transform hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
|
||||
size="lg"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center gap-3">
|
||||
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Creating Your Workflow...
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
Create Workflow
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
BIN
ui/src/app/favicon.ico
Normal file
BIN
ui/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
23
ui/src/app/global-error.tsx
Normal file
23
ui/src/app/global-error.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
"use client";
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import NextError from "next/error";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function GlobalError({ error }: { error: Error & { digest?: string } }) {
|
||||
useEffect(() => {
|
||||
Sentry.captureException(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
{/* `NextError` is the default Next.js error page component. Its type
|
||||
definition requires a `statusCode` prop. However, since the App Router
|
||||
does not expose status codes for errors, we simply pass 0 to render a
|
||||
generic error message. */}
|
||||
<NextError statusCode={0} />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
125
ui/src/app/globals.css
Normal file
125
ui/src/app/globals.css
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
@plugin "tailwindcss-animate";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
14
ui/src/app/handler/[...stack]/layout.tsx
Normal file
14
ui/src/app/handler/[...stack]/layout.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import BaseHeader from "@/components/header/BaseHeader"
|
||||
|
||||
export default function StackLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<BaseHeader />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
22
ui/src/app/handler/[...stack]/page.tsx
Normal file
22
ui/src/app/handler/[...stack]/page.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { StackHandler } from "@stackframe/stack";
|
||||
|
||||
import { stackServerApp } from "../../../stack";
|
||||
|
||||
const authProvider = process.env.NEXT_PUBLIC_AUTH_PROVIDER;
|
||||
|
||||
export default function Handler(props: unknown) {
|
||||
if (authProvider === "local") {
|
||||
// Return a simple message when using local auth
|
||||
return (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<h1>Local Auth Mode</h1>
|
||||
<p>Stack Auth handler is disabled when using local authentication.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <StackHandler
|
||||
fullPage
|
||||
app={stackServerApp}
|
||||
routeProps={props}
|
||||
/>;
|
||||
}
|
||||
44
ui/src/app/impersonate/route.ts
Normal file
44
ui/src/app/impersonate/route.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* Helper route that receives a refresh token via query parameters, stores it as
|
||||
* the regular Stack cookie *for the current sub-domain only* and finally
|
||||
* redirects the user to the requested path.
|
||||
*
|
||||
* Example usage (client side):
|
||||
* /impersonate?refresh_token=<TOKEN>&redirect_path=/workflow/123
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
const refreshToken = searchParams.get("refresh_token");
|
||||
const redirectPath = searchParams.get("redirect_path") ?? "/create-workflow";
|
||||
|
||||
if (!refreshToken) {
|
||||
return new Response("Missing refresh_token", { status: 400 });
|
||||
}
|
||||
|
||||
// Prepare redirect – if the supplied redirect path is an absolute URL we use
|
||||
// it as-is, otherwise we resolve it relative to the current request.
|
||||
const redirectUrl = redirectPath.startsWith("http")
|
||||
? redirectPath
|
||||
: new URL(redirectPath, request.url).toString();
|
||||
|
||||
const response = NextResponse.redirect(redirectUrl);
|
||||
|
||||
// One day in seconds
|
||||
const maxAge = 60 * 60 * 24;
|
||||
|
||||
// Store the refresh token cookie without an explicit domain so that it is
|
||||
// scoped to the current (sub-)domain. This avoids collisions between the
|
||||
// admin (superadmin.*) and the regular app (app.*) domains.
|
||||
response.cookies.set(`stack-refresh-${process.env.NEXT_PUBLIC_STACK_PROJECT_ID}` as string, refreshToken, {
|
||||
path: "/",
|
||||
maxAge,
|
||||
secure: true,
|
||||
httpOnly: false, // Must be accessible from the browser for Stack SDK
|
||||
sameSite: "lax",
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
69
ui/src/app/integrations/CreateIntegrationButton.tsx
Normal file
69
ui/src/app/integrations/CreateIntegrationButton.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
'use client';
|
||||
|
||||
import Nango from '@nangohq/frontend';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { createSessionApiV1IntegrationSessionPost } from "@/client/sdk.gen";
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import logger from '@/lib/logger';
|
||||
|
||||
|
||||
export default function CreateIntegrationButton() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { user, getAccessToken } = useAuth();
|
||||
|
||||
const handleCreateIntegration = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (!user) {
|
||||
throw new Error('User not authenticated');
|
||||
}
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
// Fetch session details from our API
|
||||
const sessionResponse = await createSessionApiV1IntegrationSessionPost({
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!sessionResponse.data?.session_token) {
|
||||
throw new Error('Failed to get session token');
|
||||
}
|
||||
|
||||
// Initialize Nango and open connect UI
|
||||
const nango = new Nango();
|
||||
const connect = nango.openConnectUI({
|
||||
onEvent: (event) => {
|
||||
if (event.type === 'close') {
|
||||
// Handle modal closed
|
||||
setIsLoading(false);
|
||||
logger.info('Nango connect UI closed');
|
||||
} else if (event.type === 'connect') {
|
||||
// Handle auth flow successful
|
||||
setIsLoading(false);
|
||||
logger.info('Integration connected successfully');
|
||||
// Refresh the page to show new integrations
|
||||
window.location.reload();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Set the session token to initialize the connect UI
|
||||
connect.setSessionToken(sessionResponse.data.session_token);
|
||||
|
||||
} catch (err) {
|
||||
logger.error(`Error creating integration: ${err}`);
|
||||
setIsLoading(false);
|
||||
// You might want to show a toast notification here
|
||||
alert('Failed to create integration. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button onClick={handleCreateIntegration} disabled={isLoading}>
|
||||
{isLoading ? 'Loading...' : 'Create Integration'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
14
ui/src/app/integrations/layout.tsx
Normal file
14
ui/src/app/integrations/layout.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import BaseHeader from "@/components/header/BaseHeader"
|
||||
|
||||
export default function IntegrationsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<BaseHeader />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
179
ui/src/app/integrations/page.tsx
Normal file
179
ui/src/app/integrations/page.tsx
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import { Suspense } from 'react';
|
||||
|
||||
import { getIntegrationsApiV1IntegrationGet } from "@/client/sdk.gen";
|
||||
import { getServerAccessToken,getServerAuthProvider } from '@/lib/auth/server';
|
||||
import logger from '@/lib/logger';
|
||||
|
||||
import CreateIntegrationButton from "./CreateIntegrationButton";
|
||||
|
||||
// Server component for integration list
|
||||
async function IntegrationList() {
|
||||
const authProvider = 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-gray-500">
|
||||
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-white border border-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Provider
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Channel
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Action
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Created At
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{integrations.map((integration) => (
|
||||
<tr key={integration.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{integration.provider}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{integration.provider === 'slack' && integration.provider_data ? (integration.provider_data.channel as string) || '-' : '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{integration.action}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(integration.created_at).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</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">
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="h-8 w-48 bg-gray-200 rounded"></div>
|
||||
<div className="h-10 w-32 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full bg-white border border-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Integration ID
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Channel
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Action
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Created At
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<tr key={i}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="h-4 w-32 bg-gray-200 rounded"></div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="h-4 w-24 bg-gray-200 rounded"></div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="h-4 w-24 bg-gray-200 rounded"></div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="h-4 w-24 bg-gray-200 rounded"></div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function IntegrationsPage() {
|
||||
return (
|
||||
<Suspense fallback={<IntegrationsLoading />}>
|
||||
<PageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
51
ui/src/app/layout.tsx
Normal file
51
ui/src/app/layout.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import "./globals.css";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import PostHogIdentify from "@/components/PostHogIdentify";
|
||||
import SpinLoader from "@/components/SpinLoader";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { UserConfigProvider } from "@/context/UserConfigContext";
|
||||
import { AuthProvider } from "@/lib/auth";
|
||||
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Dograh",
|
||||
description: "Open Source Voice Assistant Workflow Builder",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||
<AuthProvider>
|
||||
<Suspense fallback={<SpinLoader />}>
|
||||
<UserConfigProvider>
|
||||
<PostHogIdentify />
|
||||
{children}
|
||||
<Toaster />
|
||||
</UserConfigProvider>
|
||||
</Suspense>
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
5
ui/src/app/loading.tsx
Normal file
5
ui/src/app/loading.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export default function Loading() {
|
||||
// Stack uses React Suspense, which will render this page while user data is being fetched.
|
||||
// See: https://nextjs.org/docs/app/api-reference/file-conventions/loading
|
||||
return <></>;
|
||||
}
|
||||
20
ui/src/app/looptalk/LoopTalkLayout.tsx
Normal file
20
ui/src/app/looptalk/LoopTalkLayout.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import React, { ReactNode } from 'react'
|
||||
|
||||
import BaseHeader from '@/components/header/BaseHeader'
|
||||
|
||||
interface LoopTalkLayoutProps {
|
||||
children: ReactNode,
|
||||
headerActions?: ReactNode,
|
||||
backButton?: ReactNode,
|
||||
}
|
||||
|
||||
const LoopTalkLayout: React.FC<LoopTalkLayoutProps> = ({ children, headerActions, backButton }) => {
|
||||
return (
|
||||
<>
|
||||
<BaseHeader headerActions={headerActions} backButton={backButton} />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoopTalkLayout
|
||||
126
ui/src/app/looptalk/[id]/page.tsx
Normal file
126
ui/src/app/looptalk/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { ArrowLeft } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import { getTestSessionApiV1LooptalkTestSessionsTestSessionIdGet } from '@/client/sdk.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 logger from '@/lib/logger';
|
||||
|
||||
import LoopTalkLayout from "../LoopTalkLayout";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
async function PageContent({ params }: PageProps) {
|
||||
const authProvider = 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">
|
||||
<div className="space-y-4">
|
||||
<div className="h-32 bg-gray-200 rounded-lg animate-pulse"></div>
|
||||
<div className="h-20 bg-gray-200 rounded-lg animate-pulse"></div>
|
||||
<div className="h-64 bg-gray-200 rounded-lg animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TestSessionPage({ params }: PageProps) {
|
||||
const backButton = (
|
||||
<Link href="/looptalk">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Test Sessions
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
|
||||
return (
|
||||
<LoopTalkLayout backButton={backButton}>
|
||||
<Suspense fallback={<TestSessionLoading />}>
|
||||
<PageContent params={params} />
|
||||
</Suspense>
|
||||
</LoopTalkLayout>
|
||||
);
|
||||
}
|
||||
77
ui/src/app/looptalk/page.tsx
Normal file
77
ui/src/app/looptalk/page.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { Suspense } from 'react';
|
||||
|
||||
import { CreateTestSessionButton } from '@/components/looptalk/CreateTestSessionButton';
|
||||
import { LoopTalkTestSessionsList } from '@/components/looptalk/LoopTalkTestSessionsList';
|
||||
import { getServerAuthProvider, isServerAuthenticated } from '@/lib/auth/server';
|
||||
|
||||
import LoopTalkLayout from "./LoopTalkLayout";
|
||||
|
||||
async function PageContent() {
|
||||
const authProvider = getServerAuthProvider();
|
||||
const isAuthenticated = await isServerAuthenticated();
|
||||
|
||||
if (authProvider === 'stack' && !isAuthenticated) {
|
||||
const { redirect } = await import('next/navigation');
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Active Tests Section */}
|
||||
<div className="mb-12">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold">Active Tests</h2>
|
||||
</div>
|
||||
<LoopTalkTestSessionsList status="active" />
|
||||
</div>
|
||||
|
||||
{/* Test Sessions Section */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold">Test Sessions</h1>
|
||||
<CreateTestSessionButton />
|
||||
</div>
|
||||
<LoopTalkTestSessionsList />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoopTalkLoading() {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Active Tests Section Loading */}
|
||||
<div className="mb-12">
|
||||
<div className="h-8 w-48 bg-gray-200 rounded mb-6"></div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<div key={i} className="bg-gray-200 rounded-lg h-40"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test Sessions Section Loading */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="h-8 w-48 bg-gray-200 rounded"></div>
|
||||
<div className="h-10 w-32 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{Array.from({ length: 6 }, (_, i) => (
|
||||
<div key={i} className="bg-gray-200 rounded-lg h-32"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoopTalkPage() {
|
||||
return (
|
||||
<LoopTalkLayout>
|
||||
<Suspense fallback={<LoopTalkLoading />}>
|
||||
<PageContent />
|
||||
</Suspense>
|
||||
</LoopTalkLayout>
|
||||
);
|
||||
}
|
||||
57
ui/src/app/page.tsx
Normal file
57
ui/src/app/page.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
import SignInClient from "@/components/SignInClient";
|
||||
import { getServerAuthProvider,getServerUser } from "@/lib/auth/server";
|
||||
import logger from '@/lib/logger';
|
||||
import { getRedirectUrl } from "@/lib/utils";
|
||||
|
||||
export default async function Home() {
|
||||
const authProvider = getServerAuthProvider();
|
||||
|
||||
// For local/OSS provider, always redirect to workflow page
|
||||
if (authProvider === 'local') {
|
||||
logger.debug('Redirecting to workflow page for local provider');
|
||||
redirect('/create-workflow');
|
||||
}
|
||||
|
||||
const user = await getServerUser();
|
||||
|
||||
logger.debug(`authProvider: ${authProvider}, user: ${JSON.stringify(user)}`);
|
||||
|
||||
if (user) {
|
||||
try {
|
||||
// For Stack provider, get the token and permissions
|
||||
if (authProvider === 'stack' && 'getAuthJson' in user) {
|
||||
const token = await user.getAuthJson();
|
||||
const permissions = 'listPermissions' in user && 'selectedTeam' in user
|
||||
? await user.listPermissions(user.selectedTeam!) ?? []
|
||||
: [];
|
||||
const redirectUrl = await getRedirectUrl(token?.accessToken ?? "", permissions);
|
||||
logger.debug(`redirectUrl: ${redirectUrl}`);
|
||||
redirect(redirectUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
// If it's a Next.js redirect, let it through
|
||||
if (error instanceof Error && 'digest' in error &&
|
||||
typeof error.digest === 'string' && error.digest.startsWith('NEXT_REDIRECT')) {
|
||||
throw error;
|
||||
}
|
||||
// Only catch actual API errors
|
||||
console.error("API unavailable, showing sign-in:", error);
|
||||
// Show sign-in page if API is unavailable
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100vh",
|
||||
}}
|
||||
>
|
||||
<SignInClient />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
ui/src/app/reports/components/DispositionChart.tsx
Normal file
96
ui/src/app/reports/components/DispositionChart.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
'use client';
|
||||
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
interface DispositionData {
|
||||
disposition: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
interface DispositionChartProps {
|
||||
data: DispositionData[];
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
'#3b82f6', // blue-500
|
||||
'#10b981', // emerald-500
|
||||
'#f59e0b', // amber-500
|
||||
'#8b5cf6', // violet-500
|
||||
'#ef4444', // red-500
|
||||
'#6b7280', // gray-500 for "Other"
|
||||
];
|
||||
|
||||
export function DispositionChart({ data }: DispositionChartProps) {
|
||||
const chartData = data.map((item, index) => ({
|
||||
...item,
|
||||
fill: COLORS[index % COLORS.length],
|
||||
}));
|
||||
|
||||
const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: Array<{ payload: DispositionData & { fill: string } }> }) => {
|
||||
if (active && payload && payload[0]) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-background border rounded-lg shadow-lg p-3">
|
||||
<p className="font-semibold">{data.disposition}</p>
|
||||
<p className="text-sm">Count: {data.count}</p>
|
||||
<p className="text-sm">{data.percentage}% of total</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Disposition Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data.length === 0 ? (
|
||||
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
||||
No disposition data available
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
layout="horizontal"
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" opacity={0.1} />
|
||||
<XAxis
|
||||
dataKey="disposition"
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={80}
|
||||
interval={0}
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Bar dataKey="count" radius={[4, 4, 0, 0]}>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.fill} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
94
ui/src/app/reports/components/DurationChart.tsx
Normal file
94
ui/src/app/reports/components/DurationChart.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
'use client';
|
||||
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
interface DurationData {
|
||||
bucket: string;
|
||||
range_start: number;
|
||||
range_end: number | null;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
interface DurationChartProps {
|
||||
data: DurationData[];
|
||||
}
|
||||
|
||||
const COLORS = {
|
||||
'0-10': '#dcfce7', // green-100
|
||||
'10-30': '#bbf7d0', // green-200
|
||||
'30-60': '#86efac', // green-300
|
||||
'60-120': '#4ade80', // green-400
|
||||
'120-180': '#22c55e', // green-500
|
||||
'>180': '#16a34a', // green-600
|
||||
};
|
||||
|
||||
export function DurationChart({ data }: DurationChartProps) {
|
||||
const chartData = data.map((item) => ({
|
||||
...item,
|
||||
label: `${item.bucket}s`,
|
||||
fill: COLORS[item.bucket as keyof typeof COLORS] || '#6b7280',
|
||||
}));
|
||||
|
||||
const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: Array<{ payload: DurationData & { label: string; fill: string } }> }) => {
|
||||
if (active && payload && payload[0]) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-background border rounded-lg shadow-lg p-3">
|
||||
<p className="font-semibold">{data.label}</p>
|
||||
<p className="text-sm">Calls: {data.count}</p>
|
||||
<p className="text-sm">{data.percentage}% of total</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Call Duration Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data.length === 0 ? (
|
||||
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
||||
No duration data available
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" opacity={0.1} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Bar dataKey="count" radius={[4, 4, 0, 0]}>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.fill} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
42
ui/src/app/reports/components/MetricsCards.tsx
Normal file
42
ui/src/app/reports/components/MetricsCards.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { Phone,PhoneForwarded } from 'lucide-react';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
interface MetricsCardsProps {
|
||||
metrics: {
|
||||
total_runs: number;
|
||||
xfer_count: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function MetricsCards({ metrics }: MetricsCardsProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Workflow Runs</CardTitle>
|
||||
<Phone className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{metrics.total_runs.toLocaleString()}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Total calls processed today
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Transfer Dispositions</CardTitle>
|
||||
<PhoneForwarded className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{metrics.xfer_count.toLocaleString()}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Calls transferred (XFER)
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
ui/src/app/reports/layout.tsx
Normal file
14
ui/src/app/reports/layout.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import BaseHeader from "@/components/header/BaseHeader"
|
||||
|
||||
export default function ReportsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<BaseHeader />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
323
ui/src/app/reports/page.tsx
Normal file
323
ui/src/app/reports/page.tsx
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
'use client';
|
||||
|
||||
import { addDays, format, subDays } from 'date-fns';
|
||||
import { Calendar, ChevronLeft, ChevronRight, Download } from 'lucide-react';
|
||||
import { useEffect,useState } from 'react';
|
||||
|
||||
import {
|
||||
getDailyReportApiV1OrganizationsReportsDailyGet,
|
||||
getDailyRunsDetailApiV1OrganizationsReportsDailyRunsGet,
|
||||
getWorkflowOptionsApiV1OrganizationsReportsWorkflowsGet
|
||||
} from '@/client/sdk.gen';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Calendar as CalendarPicker } from '@/components/ui/calendar';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useUserConfig } from '@/context/UserConfigContext';
|
||||
|
||||
import { DispositionChart } from './components/DispositionChart';
|
||||
import { DurationChart } from './components/DurationChart';
|
||||
import { MetricsCards } from './components/MetricsCards';
|
||||
|
||||
interface WorkflowOption {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface DailyReport {
|
||||
date: string;
|
||||
timezone: string;
|
||||
workflow_id: number | null;
|
||||
metrics: {
|
||||
total_runs: number;
|
||||
xfer_count: number;
|
||||
};
|
||||
disposition_distribution: Array<{
|
||||
disposition: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}>;
|
||||
call_duration_distribution: Array<{
|
||||
bucket: string;
|
||||
range_start: number;
|
||||
range_end: number | null;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default function ReportsPage() {
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||||
const [selectedWorkflow, setSelectedWorkflow] = useState<string>('all');
|
||||
const [workflows, setWorkflows] = useState<WorkflowOption[]>([]);
|
||||
const [report, setReport] = useState<DailyReport | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { userConfig, accessToken } = useUserConfig();
|
||||
|
||||
const timezone = userConfig?.timezone || 'America/New_York';
|
||||
|
||||
// Fetch workflows on mount
|
||||
useEffect(() => {
|
||||
const fetchWorkflows = async () => {
|
||||
if (!accessToken) return;
|
||||
|
||||
try {
|
||||
const response = await getWorkflowOptionsApiV1OrganizationsReportsWorkflowsGet({
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
}
|
||||
});
|
||||
if (response.data) {
|
||||
setWorkflows(response.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch workflows:', err);
|
||||
}
|
||||
};
|
||||
fetchWorkflows();
|
||||
}, [accessToken]);
|
||||
|
||||
// Fetch report data when date or workflow changes
|
||||
useEffect(() => {
|
||||
const fetchReport = async () => {
|
||||
if (!accessToken) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const dateStr = format(selectedDate, 'yyyy-MM-dd');
|
||||
const workflowId = selectedWorkflow === 'all' ? undefined : parseInt(selectedWorkflow);
|
||||
|
||||
const response = await getDailyReportApiV1OrganizationsReportsDailyGet({
|
||||
query: {
|
||||
date: dateStr,
|
||||
timezone,
|
||||
...(workflowId && { workflow_id: workflowId })
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
setReport(response.data as DailyReport);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch report:', err);
|
||||
setError('Failed to load report data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchReport();
|
||||
}, [selectedDate, selectedWorkflow, timezone, accessToken]);
|
||||
|
||||
const handlePreviousDay = () => {
|
||||
setSelectedDate(subDays(selectedDate, 1));
|
||||
};
|
||||
|
||||
const handleNextDay = () => {
|
||||
setSelectedDate(addDays(selectedDate, 1));
|
||||
};
|
||||
|
||||
const handleDownloadCSV = async () => {
|
||||
if (!accessToken) return;
|
||||
|
||||
try {
|
||||
const dateStr = format(selectedDate, 'yyyy-MM-dd');
|
||||
const workflowId = selectedWorkflow === 'all' ? undefined : parseInt(selectedWorkflow);
|
||||
|
||||
// Fetch detailed runs data
|
||||
const response = await getDailyRunsDetailApiV1OrganizationsReportsDailyRunsGet({
|
||||
query: {
|
||||
date: dateStr,
|
||||
timezone,
|
||||
...(workflowId && { workflow_id: workflowId })
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data && response.data.length > 0) {
|
||||
// Prepare CSV content
|
||||
const headers = ['Phone Number', 'Disposition', 'Duration (seconds)', 'Workflow Run URL'];
|
||||
const rows = response.data.map(run => {
|
||||
const url = `${window.location.origin}/workflow/${run.workflow_id}/run/${run.run_id}`;
|
||||
return [
|
||||
run.phone_number || '',
|
||||
run.disposition || '',
|
||||
run.duration_seconds.toString(),
|
||||
url
|
||||
];
|
||||
});
|
||||
|
||||
// Create CSV content
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...rows.map(row => row.map(cell => `"${cell}"`).join(','))
|
||||
].join('\n');
|
||||
|
||||
// Create blob and download
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const workflowName = selectedWorkflow === 'all'
|
||||
? 'all_workflows'
|
||||
: workflows.find(w => w.id.toString() === selectedWorkflow)?.name?.replace(/\s+/g, '_') || 'workflow';
|
||||
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `workflow_runs_${dateStr}_${workflowName}.csv`);
|
||||
link.style.visibility = 'hidden';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} else {
|
||||
alert('No data available for download');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to download CSV:', err);
|
||||
alert('Failed to download CSV data');
|
||||
}
|
||||
};
|
||||
|
||||
const isToday = format(selectedDate, 'yyyy-MM-dd') === format(new Date(), 'yyyy-MM-dd');
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<h1 className="text-3xl font-bold">Daily Reports</h1>
|
||||
|
||||
{/* Date Navigation & Workflow Selector */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
|
||||
{/* Workflow Selector */}
|
||||
<Select value={selectedWorkflow} onValueChange={setSelectedWorkflow}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="Select workflow" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Workflows</SelectItem>
|
||||
{workflows.map((workflow) => (
|
||||
<SelectItem key={workflow.id} value={workflow.id.toString()}>
|
||||
{workflow.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Date Navigation */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handlePreviousDay}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="w-[200px]">
|
||||
<Calendar className="mr-2 h-4 w-4" />
|
||||
{format(selectedDate, 'MMM dd, yyyy')}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0">
|
||||
<CalendarPicker
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
onSelect={(date) => date && setSelectedDate(date)}
|
||||
disabled={(date) => date > new Date()}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleNextDay}
|
||||
disabled={isToday}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timezone Display and Download Button */}
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing data for {timezone} timezone
|
||||
{selectedWorkflow !== 'all' && (
|
||||
<span> • Filtered by: {workflows.find(w => w.id.toString() === selectedWorkflow)?.name}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Download CSV Button */}
|
||||
{!loading && report && report.metrics.total_runs > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownloadCSV}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download CSV
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Skeleton className="h-[120px]" />
|
||||
<Skeleton className="h-[120px]" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Skeleton className="h-[300px]" />
|
||||
<Skeleton className="h-[300px]" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && !loading && (
|
||||
<Card className="p-6">
|
||||
<p className="text-center text-red-500">{error}</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Report Content */}
|
||||
{report && !loading && !error && (
|
||||
<>
|
||||
{/* Metrics Cards */}
|
||||
<MetricsCards metrics={report.metrics} />
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<DispositionChart data={report.disposition_distribution} />
|
||||
<DurationChart data={report.call_duration_distribution} />
|
||||
</div>
|
||||
|
||||
{/* No Data Message */}
|
||||
{report.metrics.total_runs === 0 && (
|
||||
<Card className="p-6">
|
||||
<p className="text-center text-muted-foreground">
|
||||
No workflow runs found for {format(selectedDate, 'MMMM dd, yyyy')}
|
||||
{selectedWorkflow !== 'all' && ' for the selected workflow'}
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
ui/src/app/service-configurations/layout.tsx
Normal file
14
ui/src/app/service-configurations/layout.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import BaseHeader from "@/components/header/BaseHeader"
|
||||
|
||||
export default function ServiceConfigurationLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<BaseHeader />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
13
ui/src/app/service-configurations/page.tsx
Normal file
13
ui/src/app/service-configurations/page.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import ServiceConfiguration from "@/components/ServiceConfiguration";
|
||||
|
||||
export default function ServiceConfigurationPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<ServiceConfiguration />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
ui/src/app/superadmin/layout.tsx
Normal file
14
ui/src/app/superadmin/layout.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import BaseHeader from "@/components/header/BaseHeader"
|
||||
|
||||
export default function SuperAdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<BaseHeader />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
134
ui/src/app/superadmin/page.tsx
Normal file
134
ui/src/app/superadmin/page.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowRight, List, Loader2 } from 'lucide-react';
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import { impersonateAsSuperadmin } from "@/lib/utils";
|
||||
|
||||
export default function SuperadminPage() {
|
||||
const [userId, setUserId] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { user, getAccessToken } = useAuth();
|
||||
|
||||
const handleImpersonate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
if (!user) {
|
||||
setError("User not authenticated. Please log in and try again.");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
const accessToken = await getAccessToken();
|
||||
if (!accessToken) {
|
||||
throw new Error('Missing admin access token');
|
||||
}
|
||||
|
||||
await impersonateAsSuperadmin({
|
||||
accessToken: accessToken,
|
||||
providerUserId: userId,
|
||||
redirectPath: '/workflow',
|
||||
openInNewTab: true,
|
||||
});
|
||||
} catch (err) {
|
||||
setError("Failed to impersonate user. Please try again.");
|
||||
console.error("Impersonation error:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="min-h-[calc(100vh-73px)] bg-gray-50 px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Superadmin Dashboard</h1>
|
||||
<p className="text-sm text-gray-600">Manage users and view system-wide data</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* User Impersonation Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>User Impersonation</CardTitle>
|
||||
<CardDescription>
|
||||
Impersonate a user account for debugging or support purposes
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleImpersonate} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="userId">Provider User ID</Label>
|
||||
<Input
|
||||
id="userId"
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
placeholder="Enter provider user ID"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
'Impersonate User'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Workflow Runs Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Workflow Runs</CardTitle>
|
||||
<CardDescription>
|
||||
View and manage all workflow runs across organizations
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
Access detailed information about all workflow runs, including status,
|
||||
recordings, transcripts, and usage data.
|
||||
</p>
|
||||
<Link href="/superadmin/runs">
|
||||
<Button className="w-full">
|
||||
<List className="mr-2 h-4 w-4" />
|
||||
View All Runs
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
677
ui/src/app/superadmin/runs/page.tsx
Normal file
677
ui/src/app/superadmin/runs/page.tsx
Normal file
|
|
@ -0,0 +1,677 @@
|
|||
"use client";
|
||||
|
||||
import { AlertTriangle, CheckCircle, ChevronLeft, ChevronRight, ExternalLink, Info, Loader2, MessageSquare, RefreshCw } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { getWorkflowRunsApiV1SuperuserWorkflowRunsGet, setAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPost } from '@/client/sdk.gen';
|
||||
import { FilterBuilder } from "@/components/filters/FilterBuilder";
|
||||
import { MediaPreviewButtons, MediaPreviewDialog } from '@/components/MediaPreviewDialog';
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useUserConfig } from '@/context/UserConfigContext';
|
||||
import { getDispositionBadgeVariant } from '@/lib/dispositionBadgeVariant';
|
||||
import{ superadminFilterAttributes } from "@/lib/filterAttributes";
|
||||
import { decodeFiltersFromURL, encodeFiltersToURL } from '@/lib/filters';
|
||||
import { impersonateAsSuperadmin } from '@/lib/utils';
|
||||
import { ActiveFilter } from '@/types/filters';
|
||||
|
||||
interface WorkflowRun {
|
||||
id: number;
|
||||
name: string;
|
||||
workflow_id: number;
|
||||
workflow_name?: string;
|
||||
user_id?: number;
|
||||
organization_id?: number;
|
||||
organization_name?: string;
|
||||
mode: string;
|
||||
is_completed: boolean;
|
||||
recording_url?: string;
|
||||
transcript_url?: string;
|
||||
usage_info?: Record<string, unknown>;
|
||||
cost_info?: Record<string, unknown>;
|
||||
initial_context?: Record<string, unknown>;
|
||||
gathered_context?: Record<string, unknown>;
|
||||
admin_comment?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface WorkflowRunsResponse {
|
||||
workflow_runs: WorkflowRun[];
|
||||
total_count: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
|
||||
export default function RunsPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [runs, setRuns] = useState<WorkflowRun[]>([]);
|
||||
const [currentPage, setCurrentPage] = useState(() => {
|
||||
const pageParam = searchParams.get('page');
|
||||
return pageParam ? parseInt(pageParam, 10) : 1;
|
||||
});
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [isExecutingFilters, setIsExecutingFilters] = useState(false);
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
const [isAutoRefreshing, setIsAutoRefreshing] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(Date.now());
|
||||
const limit = 50;
|
||||
|
||||
// Initialize filters from URL
|
||||
const [activeFilters, setActiveFilters] = useState<ActiveFilter[]>(() => {
|
||||
return decodeFiltersFromURL(searchParams, superadminFilterAttributes);
|
||||
});
|
||||
|
||||
// Dialog state for comment editing
|
||||
const [isCommentDialogOpen, setIsCommentDialogOpen] = useState(false);
|
||||
const [commentRunId, setCommentRunId] = useState<number | null>(null);
|
||||
const [commentText, setCommentText] = useState('');
|
||||
const [selectedRowId, setSelectedRowId] = useState<number | null>(null);
|
||||
|
||||
const { accessToken } = useUserConfig();
|
||||
|
||||
// Media preview dialog
|
||||
const mediaPreview = MediaPreviewDialog({ accessToken });
|
||||
|
||||
const fetchRuns = useCallback(async (page: number, filters?: ActiveFilter[], isAutoRefresh = false) => {
|
||||
if (!accessToken) return;
|
||||
|
||||
// Don't show loading state for auto-refresh to prevent UI flicker
|
||||
if (!isAutoRefresh) {
|
||||
setIsLoading(true);
|
||||
} else {
|
||||
setIsAutoRefreshing(true);
|
||||
}
|
||||
setError("");
|
||||
|
||||
try {
|
||||
let filterParam = undefined;
|
||||
if (filters && filters.length > 0) {
|
||||
const filterData = filters.map(filter => ({
|
||||
attribute: filter.attribute.id,
|
||||
type: filter.attribute.type,
|
||||
value: filter.value,
|
||||
}));
|
||||
filterParam = JSON.stringify(filterData);
|
||||
}
|
||||
|
||||
const response = await getWorkflowRunsApiV1SuperuserWorkflowRunsGet({
|
||||
query: {
|
||||
page,
|
||||
limit,
|
||||
...(filterParam && { filters: filterParam })
|
||||
},
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
const data = response.data as WorkflowRunsResponse;
|
||||
setRuns(data.workflow_runs);
|
||||
setCurrentPage(data.page);
|
||||
setTotalPages(data.total_pages);
|
||||
setTotalCount(data.total_count);
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Failed to fetch workflow runs. Please try again.");
|
||||
console.error("Fetch runs error:", err);
|
||||
} finally {
|
||||
if (!isAutoRefresh) {
|
||||
setIsLoading(false);
|
||||
} else {
|
||||
setIsAutoRefreshing(false);
|
||||
}
|
||||
}
|
||||
}, [limit, accessToken]);
|
||||
|
||||
const updatePageInUrl = useCallback((page: number, filters?: ActiveFilter[]) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', page.toString());
|
||||
|
||||
// Add filters to URL if present
|
||||
if (filters && filters.length > 0) {
|
||||
const filterString = encodeFiltersToURL(filters);
|
||||
if (filterString) {
|
||||
const filterParams = new URLSearchParams(filterString);
|
||||
filterParams.forEach((value, key) => params.set(key, value));
|
||||
}
|
||||
}
|
||||
|
||||
router.push(`/superadmin/runs?${params.toString()}`);
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch runs when token is available and when page changes
|
||||
if (accessToken) {
|
||||
fetchRuns(currentPage, activeFilters);
|
||||
}
|
||||
}, [currentPage, accessToken, activeFilters, fetchRuns]);
|
||||
|
||||
// Auto-refresh every 5 seconds when enabled and filters are active
|
||||
useEffect(() => {
|
||||
// Only set up interval if auto-refresh is enabled and there are active filters
|
||||
if (!autoRefresh || activeFilters.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
// Pass true to indicate this is an auto-refresh
|
||||
fetchRuns(currentPage, activeFilters, true);
|
||||
}, 5000);
|
||||
|
||||
// Cleanup interval on unmount or when dependencies change
|
||||
return () => clearInterval(intervalId);
|
||||
}, [currentPage, activeFilters, fetchRuns, autoRefresh]);
|
||||
|
||||
// Update current time every second to show live duration for running calls
|
||||
useEffect(() => {
|
||||
const hasRunningCalls = runs.some(run => !run.is_completed);
|
||||
if (!hasRunningCalls) {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
setCurrentTime(Date.now());
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [runs]);
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
updatePageInUrl(page, activeFilters);
|
||||
fetchRuns(page, activeFilters);
|
||||
};
|
||||
|
||||
const handleApplyFilters = useCallback(async () => {
|
||||
setIsExecutingFilters(true);
|
||||
setCurrentPage(1); // Reset to first page when applying filters
|
||||
updatePageInUrl(1, activeFilters);
|
||||
await fetchRuns(1, activeFilters);
|
||||
setIsExecutingFilters(false);
|
||||
}, [activeFilters, fetchRuns, updatePageInUrl]);
|
||||
|
||||
const handleFiltersChange = useCallback((filters: ActiveFilter[]) => {
|
||||
setActiveFilters(filters);
|
||||
}, []);
|
||||
|
||||
const handleClearFilters = useCallback(async () => {
|
||||
setIsExecutingFilters(true);
|
||||
setCurrentPage(1);
|
||||
updatePageInUrl(1, []); // Clear filters from URL
|
||||
await fetchRuns(1, []); // Fetch all runs without filters
|
||||
setIsExecutingFilters(false);
|
||||
}, [fetchRuns, updatePageInUrl]);
|
||||
|
||||
// Save comment function declared outside JSX (requirement #2)
|
||||
const saveAdminComment = useCallback(async () => {
|
||||
if (commentRunId === null || !accessToken) return;
|
||||
try {
|
||||
await setAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPost({
|
||||
path: {
|
||||
run_id: commentRunId,
|
||||
},
|
||||
body: {
|
||||
admin_comment: commentText,
|
||||
},
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
// Optimistically update UI
|
||||
setRuns(prev => prev.map(r => r.id === commentRunId ? { ...r, admin_comment: commentText } : r));
|
||||
|
||||
setIsCommentDialogOpen(false);
|
||||
} catch (err) {
|
||||
console.error('Failed to set admin comment', err);
|
||||
alert('Failed to save comment. Please try again.');
|
||||
}
|
||||
}, [commentRunId, commentText, accessToken]);
|
||||
|
||||
/**
|
||||
* ----------------------------------------------------------------------------------
|
||||
* Helpers
|
||||
* ----------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const formatDate = (dateString: string) => new Date(dateString).toLocaleString();
|
||||
|
||||
const calculateDuration = (createdAt: string, isCompleted: boolean, usageInfo?: Record<string, unknown>) => {
|
||||
if (isCompleted && typeof usageInfo?.call_duration_seconds === 'number') {
|
||||
return `${Number(usageInfo.call_duration_seconds).toFixed(2)}s`;
|
||||
}
|
||||
|
||||
if (!isCompleted) {
|
||||
const startTime = new Date(createdAt).getTime();
|
||||
const duration = Math.floor((currentTime - startTime) / 1000);
|
||||
|
||||
// If duration exceeds 5 minutes (300 seconds), show "-" as it's likely an error
|
||||
if (duration > 300) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
if (duration < 60) {
|
||||
return `${duration}s`;
|
||||
} else {
|
||||
const minutes = Math.floor(duration / 60);
|
||||
const seconds = duration % 60;
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
}
|
||||
|
||||
return '-';
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Wrapper around shared impersonation util – we only need to fetch the
|
||||
* current superadmin token and then delegate the heavy lifting.
|
||||
*/
|
||||
const impersonateAndMaybeRedirect = useCallback(
|
||||
async (targetUserId: number | undefined, redirectPath?: string) => {
|
||||
if (!targetUserId || !accessToken) return;
|
||||
try {
|
||||
await impersonateAsSuperadmin({
|
||||
accessToken: accessToken,
|
||||
userId: targetUserId,
|
||||
redirectPath,
|
||||
openInNewTab: true,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to impersonate user', err);
|
||||
alert('Failed to impersonate the user. Please try again.');
|
||||
}
|
||||
},
|
||||
[accessToken],
|
||||
);
|
||||
|
||||
if (isLoading && runs.length === 0) {
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-73px)] bg-gray-50 flex items-center justify-center">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
<span>Loading workflow runs...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-73px)] bg-gray-50 p-6">
|
||||
<div className="max-w-full mx-auto">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Workflow Runs</h1>
|
||||
<p className="text-gray-600">View and manage all workflow runs across organizations</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FilterBuilder
|
||||
availableAttributes={superadminFilterAttributes}
|
||||
activeFilters={activeFilters}
|
||||
onFiltersChange={handleFiltersChange}
|
||||
onApplyFilters={handleApplyFilters}
|
||||
onClearFilters={handleClearFilters}
|
||||
isExecuting={isExecutingFilters}
|
||||
autoRefresh={autoRefresh}
|
||||
onAutoRefreshChange={setAutoRefresh}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>All Workflow Runs</CardTitle>
|
||||
<CardDescription>
|
||||
Showing {runs.length} of {totalCount} total runs
|
||||
</CardDescription>
|
||||
</div>
|
||||
{isAutoRefreshing && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
<span>Refreshing...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{runs.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
No workflow runs found.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="bg-white border rounded-lg overflow-hidden shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead className="font-semibold">ID</TableHead>
|
||||
<TableHead className="font-semibold">Workflow</TableHead>
|
||||
<TableHead className="font-semibold">Status</TableHead>
|
||||
<TableHead className="font-semibold">Disposition</TableHead>
|
||||
<TableHead className="font-semibold">Tags</TableHead>
|
||||
<TableHead className="font-semibold">Comment</TableHead>
|
||||
<TableHead className="font-semibold">Duration</TableHead>
|
||||
<TableHead className="font-semibold">Dograh Token</TableHead>
|
||||
<TableHead className="font-semibold">Created At</TableHead>
|
||||
<TableHead className="font-semibold">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{runs.map((run) => (
|
||||
<TableRow
|
||||
key={run.id}
|
||||
className={selectedRowId === run.id ? "bg-blue-50" : ""}>
|
||||
<TableCell className="font-mono text-sm">
|
||||
#{run.id}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-sm">
|
||||
{run.workflow_name ? (
|
||||
run.workflow_name.length > 15
|
||||
? `${run.workflow_name.substring(0, 15)}...`
|
||||
: run.workflow_name
|
||||
) : 'Unknown Workflow'}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 font-mono">
|
||||
ID: {String(run.workflow_id).length > 12
|
||||
? `${String(run.workflow_id).substring(0, 12)}...`
|
||||
: run.workflow_id}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{run.is_completed ? (
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
) : (
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-500" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{run.gathered_context?.mapped_call_disposition ? (
|
||||
<Badge variant={getDispositionBadgeVariant(run.gathered_context.mapped_call_disposition as string)}>
|
||||
{run.gathered_context.mapped_call_disposition as string}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{Array.isArray(run.gathered_context?.call_tags) && run.gathered_context.call_tags.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{run.gathered_context.call_tags.map((tag: string) => (
|
||||
<Badge key={tag} variant="default">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-sm whitespace-pre-wrap break-words">
|
||||
{run.admin_comment ? (
|
||||
<span>{run.admin_comment}</span>
|
||||
) : (
|
||||
<span className="text-gray-400 italic">No comment</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm whitespace-pre-wrap break-words">
|
||||
<span className={!run.is_completed ? "font-semibold text-blue-600" : ""}>
|
||||
{calculateDuration(run.created_at, run.is_completed, run.usage_info)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>
|
||||
{typeof run.cost_info?.total_cost_usd === 'number'
|
||||
? `${Number(run.cost_info.total_cost_usd * 100).toFixed(2)}`
|
||||
: '-'}
|
||||
</span>
|
||||
{(run.usage_info || run.cost_info) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-4 w-4 text-gray-500 cursor-pointer" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={4} className="max-w-xs whitespace-pre-wrap break-words">
|
||||
<pre className="max-w-xs whitespace-pre-wrap break-words">
|
||||
{`Usage Info: ${JSON.stringify(run.usage_info ?? {}, null, 2)}\n\nCost Info: ${JSON.stringify(run.cost_info ?? {}, null, 2)}`}
|
||||
</pre>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{formatDate(run.created_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex space-x-2">
|
||||
<MediaPreviewButtons
|
||||
recordingUrl={run.recording_url}
|
||||
transcriptUrl={run.transcript_url}
|
||||
runId={run.id}
|
||||
onOpenAudio={mediaPreview.openAudioModal}
|
||||
onOpenTranscript={mediaPreview.openTranscriptModal}
|
||||
onSelect={setSelectedRowId}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
const query = encodeURIComponent(
|
||||
JSON.stringify({
|
||||
children: [
|
||||
{
|
||||
field: 'extra.run_id',
|
||||
op: '==',
|
||||
value: run.id,
|
||||
},
|
||||
],
|
||||
field: '',
|
||||
op: 'and',
|
||||
}),
|
||||
);
|
||||
window.open(
|
||||
`https://app.axiom.co/dograh-of6c/stream/${process.env.NEXT_PUBLIC_AXIOM_LOG_DATASET}?q=${query}`,
|
||||
'_blank',
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src="/axiom_icon.svg"
|
||||
alt="Traces"
|
||||
width={16}
|
||||
height={16}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
const filter = encodeURIComponent(
|
||||
`metadata;stringObject;attributes;contains;conversation.id,metadata;stringObject;attributes;contains;${run.id}`,
|
||||
);
|
||||
window.open(
|
||||
`${process.env.NEXT_PUBLIC_LANGFUSE_ENDPOINT}/project/${process.env.NEXT_PUBLIC_LANGFUSE_PROJECT_ID}/traces?search=&filter=${filter}&dateRange=All+time`,
|
||||
'_blank',
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src="/langfuse_icon.svg"
|
||||
alt="Langfuse Traces"
|
||||
width={16}
|
||||
height={16}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{/* Quick-link to open the workflow inside the *regular* app after
|
||||
successfully impersonating the owner of the workflow. */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
title="Open workflow as user"
|
||||
onClick={() => {
|
||||
const appBaseUrl = window.location.origin.includes('superadmin.')
|
||||
? window.location.origin.replace('superadmin.', 'app.')
|
||||
: window.location.origin;
|
||||
impersonateAndMaybeRedirect(
|
||||
run.user_id,
|
||||
`${appBaseUrl}/workflow/${run.workflow_id}`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setCommentRunId(run.id);
|
||||
setCommentText(run.admin_comment || '');
|
||||
setIsCommentDialogOpen(true);
|
||||
}}
|
||||
title="Add/Edit Comment"
|
||||
>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-6">
|
||||
<div className="text-sm text-gray-500">
|
||||
Page {currentPage} of {totalPages} ({totalCount} total runs)
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1 || isLoading}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
{/* Page numbers */}
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
let pageNum;
|
||||
if (totalPages <= 5) {
|
||||
pageNum = i + 1;
|
||||
} else if (currentPage <= 3) {
|
||||
pageNum = i + 1;
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
pageNum = totalPages - 4 + i;
|
||||
} else {
|
||||
pageNum = currentPage - 2 + i;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={pageNum}
|
||||
variant={currentPage === pageNum ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(pageNum)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{pageNum}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages || isLoading}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Comment Dialog */}
|
||||
<Dialog open={isCommentDialogOpen} onOpenChange={setIsCommentDialogOpen}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{commentRunId ? 'Edit Comment' : 'Add Comment'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Admin-only comment for run #{commentRunId}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Textarea
|
||||
value={commentText}
|
||||
onChange={(e) => setCommentText(e.target.value)}
|
||||
placeholder="Enter comment here..."
|
||||
className="min-h-[120px]"
|
||||
/>
|
||||
|
||||
<DialogFooter className="pt-4">
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button onClick={saveAdminComment}>Save</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Media Preview Dialog */}
|
||||
{mediaPreview.dialog}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
ui/src/app/usage/layout.tsx
Normal file
14
ui/src/app/usage/layout.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import BaseHeader from "@/components/header/BaseHeader"
|
||||
|
||||
export default function UsageLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<BaseHeader />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
590
ui/src/app/usage/page.tsx
Normal file
590
ui/src/app/usage/page.tsx
Normal file
|
|
@ -0,0 +1,590 @@
|
|||
"use client";
|
||||
|
||||
import { Calendar, ChevronLeft, ChevronRight, Globe } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import TimezoneSelect, { type ITimezoneOption } from 'react-timezone-select';
|
||||
|
||||
import { getCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGet, getDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGet,getUsageHistoryApiV1OrganizationsUsageRunsGet } from '@/client/sdk.gen';
|
||||
import type { CurrentUsageResponse, DailyUsageBreakdownResponse,UsageHistoryResponse, WorkflowRunUsageResponse } from '@/client/types.gen';
|
||||
import { DailyUsageTable } from '@/components/DailyUsageTable';
|
||||
import { FilterBuilder } from '@/components/filters/FilterBuilder';
|
||||
import { MediaPreviewButtons, MediaPreviewDialog } from '@/components/MediaPreviewDialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { useUserConfig } from '@/context/UserConfigContext';
|
||||
import { getDispositionBadgeVariant } from '@/lib/dispositionBadgeVariant';
|
||||
import { usageFilterAttributes } from '@/lib/filterAttributes';
|
||||
import { decodeFiltersFromURL, encodeFiltersToURL } from '@/lib/filters';
|
||||
import { ActiveFilter, DateRangeValue } from '@/types/filters';
|
||||
|
||||
// Get local timezone
|
||||
const getLocalTimezone = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
export default function UsagePage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { userConfig, saveUserConfig, loading: userConfigLoading, accessToken, organizationPricing } = useUserConfig();
|
||||
|
||||
// Current usage state
|
||||
const [currentUsage, setCurrentUsage] = useState<CurrentUsageResponse | null>(null);
|
||||
const [isLoadingCurrent, setIsLoadingCurrent] = useState(true);
|
||||
|
||||
// Usage history state
|
||||
const [usageHistory, setUsageHistory] = useState<UsageHistoryResponse | null>(null);
|
||||
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(() => {
|
||||
const pageParam = searchParams.get('page');
|
||||
return pageParam ? parseInt(pageParam, 10) : 1;
|
||||
});
|
||||
const [isExecutingFilters, setIsExecutingFilters] = useState(false);
|
||||
|
||||
// Daily usage breakdown state (only for paid orgs)
|
||||
const [dailyUsage, setDailyUsage] = useState<DailyUsageBreakdownResponse | null>(null);
|
||||
const [isLoadingDaily, setIsLoadingDaily] = useState(false);
|
||||
|
||||
// Initialize filters from URL
|
||||
const [activeFilters, setActiveFilters] = useState<ActiveFilter[]>(() => {
|
||||
return decodeFiltersFromURL(searchParams, usageFilterAttributes);
|
||||
});
|
||||
|
||||
// Media preview dialog
|
||||
const mediaPreview = MediaPreviewDialog({ accessToken });
|
||||
|
||||
// Timezone state - wait for userConfig to load before setting default
|
||||
const localTimezone = getLocalTimezone();
|
||||
const [selectedTimezone, setSelectedTimezone] = useState<ITimezoneOption | string>(() => {
|
||||
// Only use local timezone if we know for sure there's no saved timezone
|
||||
// (i.e., userConfig has loaded and has no timezone)
|
||||
if (!userConfigLoading && !userConfig?.timezone) {
|
||||
return localTimezone;
|
||||
}
|
||||
// Otherwise return the saved timezone or empty string while loading
|
||||
return userConfig?.timezone || '';
|
||||
});
|
||||
const [savingTimezone, setSavingTimezone] = useState(false);
|
||||
|
||||
// Fetch current usage
|
||||
const fetchCurrentUsage = useCallback(async () => {
|
||||
if (!accessToken) return;
|
||||
try {
|
||||
const response = await getCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGet({
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
setCurrentUsage(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch current usage:', error);
|
||||
} finally {
|
||||
setIsLoadingCurrent(false);
|
||||
}
|
||||
}, [accessToken]);
|
||||
|
||||
// Fetch usage history
|
||||
const fetchUsageHistory = useCallback(async (page: number, filters?: ActiveFilter[]) => {
|
||||
if (!accessToken) return;
|
||||
setIsLoadingHistory(true);
|
||||
try {
|
||||
let filterParam = undefined;
|
||||
let startDate = '';
|
||||
let endDate = '';
|
||||
|
||||
if (filters && filters.length > 0) {
|
||||
// Extract date range filter if present
|
||||
const dateRangeFilter = filters.find(f => f.attribute.id === 'dateRange');
|
||||
if (dateRangeFilter && dateRangeFilter.value) {
|
||||
const dateValue = dateRangeFilter.value as DateRangeValue;
|
||||
|
||||
if (dateValue.from) {
|
||||
// The dates are already in the user's local timezone
|
||||
// Convert to UTC ISO string for the backend
|
||||
startDate = dateValue.from.toISOString();
|
||||
}
|
||||
if (dateValue.to) {
|
||||
// Convert to UTC ISO string for the backend
|
||||
endDate = dateValue.to.toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
// Process other filters (excluding dateRange)
|
||||
const otherFilters = filters.filter(f => f.attribute.id !== 'dateRange');
|
||||
if (otherFilters.length > 0) {
|
||||
const filterData = otherFilters.map(filter => ({
|
||||
attribute: filter.attribute.id,
|
||||
type: filter.attribute.type,
|
||||
value: filter.value,
|
||||
}));
|
||||
filterParam = JSON.stringify(filterData);
|
||||
}
|
||||
}
|
||||
|
||||
const response = await getUsageHistoryApiV1OrganizationsUsageRunsGet({
|
||||
query: {
|
||||
page,
|
||||
limit: 50,
|
||||
...(startDate && { start_date: startDate }),
|
||||
...(endDate && { end_date: endDate }),
|
||||
...(filterParam && { filters: filterParam })
|
||||
},
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
setUsageHistory(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch usage history:', error);
|
||||
} finally {
|
||||
setIsLoadingHistory(false);
|
||||
}
|
||||
}, [accessToken]);
|
||||
|
||||
// Fetch daily usage breakdown
|
||||
const fetchDailyUsage = useCallback(async () => {
|
||||
if (!accessToken || !organizationPricing?.price_per_second_usd) return;
|
||||
|
||||
setIsLoadingDaily(true);
|
||||
try {
|
||||
const response = await getDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGet({
|
||||
query: { days: 7 },
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
setDailyUsage(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch daily usage:', error);
|
||||
} finally {
|
||||
setIsLoadingDaily(false);
|
||||
}
|
||||
}, [accessToken, organizationPricing]);
|
||||
|
||||
// Handle timezone change
|
||||
const handleTimezoneChange = async (timezone: ITimezoneOption | string) => {
|
||||
setSelectedTimezone(timezone);
|
||||
setSavingTimezone(true);
|
||||
try {
|
||||
const tzValue = typeof timezone === 'string' ? timezone : timezone.value;
|
||||
await saveUserConfig({ timezone: tzValue });
|
||||
} catch (error) {
|
||||
console.error('Failed to save timezone:', error);
|
||||
// Revert to previous timezone on error
|
||||
const prevTz = userConfig?.timezone || localTimezone;
|
||||
setSelectedTimezone(prevTz);
|
||||
} finally {
|
||||
setSavingTimezone(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Update timezone when userConfig loads
|
||||
useEffect(() => {
|
||||
if (!userConfigLoading) {
|
||||
// Config has loaded - set the timezone
|
||||
if (userConfig?.timezone) {
|
||||
setSelectedTimezone(userConfig.timezone);
|
||||
} else {
|
||||
// No saved timezone, use local
|
||||
setSelectedTimezone(localTimezone);
|
||||
}
|
||||
}
|
||||
}, [userConfig, userConfigLoading, localTimezone]);
|
||||
|
||||
// Initial load - fetch when accessToken becomes available
|
||||
useEffect(() => {
|
||||
if (accessToken) {
|
||||
fetchCurrentUsage();
|
||||
fetchUsageHistory(currentPage, activeFilters);
|
||||
}
|
||||
}, [accessToken, currentPage, activeFilters, fetchUsageHistory, fetchCurrentUsage]);
|
||||
|
||||
// Fetch daily usage when organizationPricing becomes available
|
||||
useEffect(() => {
|
||||
if (accessToken && organizationPricing?.price_per_second_usd) {
|
||||
fetchDailyUsage();
|
||||
}
|
||||
}, [accessToken, organizationPricing, fetchDailyUsage]);
|
||||
|
||||
// Update URL with query parameters
|
||||
const updateUrlParams = useCallback((params: { page?: number; filters?: ActiveFilter[] }) => {
|
||||
const newParams = new URLSearchParams();
|
||||
|
||||
if (params.page !== undefined) {
|
||||
newParams.set('page', params.page.toString());
|
||||
}
|
||||
|
||||
// Add filters to URL if present
|
||||
if (params.filters && params.filters.length > 0) {
|
||||
const filterString = encodeFiltersToURL(params.filters);
|
||||
if (filterString) {
|
||||
const filterParams = new URLSearchParams(filterString);
|
||||
filterParams.forEach((value, key) => newParams.set(key, value));
|
||||
}
|
||||
}
|
||||
|
||||
router.push(`/usage?${newParams.toString()}`);
|
||||
}, [router]);
|
||||
|
||||
const handleApplyFilters = useCallback(async () => {
|
||||
setIsExecutingFilters(true);
|
||||
setCurrentPage(1); // Reset to first page when applying filters
|
||||
updateUrlParams({ page: 1, filters: activeFilters });
|
||||
await fetchUsageHistory(1, activeFilters);
|
||||
setIsExecutingFilters(false);
|
||||
}, [activeFilters, fetchUsageHistory, updateUrlParams]);
|
||||
|
||||
const handleFiltersChange = useCallback((filters: ActiveFilter[]) => {
|
||||
setActiveFilters(filters);
|
||||
}, []);
|
||||
|
||||
const handleClearFilters = useCallback(async () => {
|
||||
setIsExecutingFilters(true);
|
||||
setCurrentPage(1);
|
||||
updateUrlParams({ page: 1, filters: [] }); // Clear filters from URL
|
||||
await fetchUsageHistory(1, []); // Fetch all runs without filters
|
||||
setIsExecutingFilters(false);
|
||||
}, [fetchUsageHistory, updateUrlParams]);
|
||||
|
||||
// Handle page change
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setCurrentPage(newPage);
|
||||
updateUrlParams({ page: newPage, filters: activeFilters });
|
||||
fetchUsageHistory(newPage, activeFilters);
|
||||
};
|
||||
|
||||
// Handle row click to navigate to workflow run
|
||||
const handleRowClick = (run: WorkflowRunUsageResponse) => {
|
||||
router.push(`/workflow/${run.workflow_id}/run/${run.id}`);
|
||||
};
|
||||
|
||||
// Format date for display with timezone support
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const tzValue = typeof selectedTimezone === 'string' ? selectedTimezone : selectedTimezone.value;
|
||||
// Use local timezone if none selected (during loading)
|
||||
const effectiveTz = tzValue || localTimezone;
|
||||
return date.toLocaleDateString('en-US', {
|
||||
timeZone: effectiveTz,
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
// Format datetime for display with timezone support
|
||||
const formatDateTime = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const tzValue = typeof selectedTimezone === 'string' ? selectedTimezone : selectedTimezone.value;
|
||||
// Use local timezone if none selected (during loading)
|
||||
const effectiveTz = tzValue || localTimezone;
|
||||
return date.toLocaleString('en-US', {
|
||||
timeZone: effectiveTz,
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
};
|
||||
|
||||
// Format duration for display
|
||||
const formatDuration = (seconds: number) => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
if (minutes === 0) return `${remainingSeconds}s`;
|
||||
if (remainingSeconds === 0) return `${minutes}m`;
|
||||
return `${minutes}m ${remainingSeconds}s`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-73px)] bg-gray-50 p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Usage Dashboard</h1>
|
||||
<p className="text-gray-600">Monitor your Dograh Token usage and quota</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4 text-gray-500" />
|
||||
<div className="w-[300px]">
|
||||
<TimezoneSelect
|
||||
value={selectedTimezone}
|
||||
onChange={handleTimezoneChange}
|
||||
isDisabled={savingTimezone || userConfigLoading}
|
||||
placeholder={userConfigLoading ? "Loading..." : "Select timezone"}
|
||||
styles={{
|
||||
control: (base) => ({
|
||||
...base,
|
||||
minHeight: '36px',
|
||||
fontSize: '14px',
|
||||
}),
|
||||
menu: (base) => ({
|
||||
...base,
|
||||
zIndex: 9999,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Period Card */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Current Billing Period</CardTitle>
|
||||
<CardDescription>
|
||||
{currentUsage && `${formatDate(currentUsage.period_start)} - ${formatDate(currentUsage.period_end)}`}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingCurrent ? (
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/4"></div>
|
||||
<div className="h-8 bg-gray-200 rounded"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-1/3"></div>
|
||||
</div>
|
||||
) : currentUsage ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-baseline">
|
||||
<div>
|
||||
{organizationPricing?.price_per_second_usd ? (
|
||||
<>
|
||||
<p className="text-2xl font-bold">
|
||||
${(currentUsage.used_amount_usd || 0).toFixed(2)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">Total Cost (USD)</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Rate: ${(organizationPricing.price_per_second_usd * 60).toFixed(4)}/minute
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-2xl font-bold">
|
||||
{currentUsage.used_dograh_tokens.toLocaleString()} / {currentUsage.quota_dograh_tokens.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">Dograh Tokens</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!organizationPricing?.price_per_second_usd && (
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-semibold">{currentUsage.percentage_used}%</p>
|
||||
<p className="text-sm text-gray-600">Used</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!organizationPricing?.price_per_second_usd && (
|
||||
<Progress value={currentUsage.percentage_used} className="h-3" />
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center text-sm text-gray-600">
|
||||
<div className="flex items-center">
|
||||
<Calendar className="h-4 w-4 mr-1" />
|
||||
Next refresh: {formatDate(currentUsage.next_refresh_date)}
|
||||
</div>
|
||||
<div>
|
||||
Total Duration: <span className="font-medium text-gray-900">{formatDuration(currentUsage.total_duration_seconds)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!currentUsage.quota_enabled && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-3">
|
||||
<p className="text-sm text-yellow-800">
|
||||
Quota enforcement is not enabled for your organization.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500">Unable to load usage data</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Daily Usage Table - Only for paid organizations */}
|
||||
{organizationPricing?.price_per_second_usd && (
|
||||
<div className="mb-6">
|
||||
<DailyUsageTable
|
||||
data={dailyUsage}
|
||||
isLoading={isLoadingDaily}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter Builder */}
|
||||
<div className="mb-6">
|
||||
<FilterBuilder
|
||||
availableAttributes={usageFilterAttributes}
|
||||
activeFilters={activeFilters}
|
||||
onFiltersChange={handleFiltersChange}
|
||||
onApplyFilters={handleApplyFilters}
|
||||
onClearFilters={handleClearFilters}
|
||||
isExecuting={isExecutingFilters}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Usage History */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle>Usage History</CardTitle>
|
||||
<CardDescription>
|
||||
View detailed usage by workflow run
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingHistory ? (
|
||||
<div className="animate-pulse space-y-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="h-12 bg-gray-200 rounded"></div>
|
||||
))}
|
||||
</div>
|
||||
) : usageHistory && usageHistory.runs.length > 0 ? (
|
||||
<>
|
||||
<div className="bg-white border rounded-lg overflow-hidden shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead className="font-semibold">Run ID</TableHead>
|
||||
<TableHead className="font-semibold">Workflow Name</TableHead>
|
||||
<TableHead className="font-semibold">Phone Number</TableHead>
|
||||
<TableHead className="font-semibold">Disposition</TableHead>
|
||||
<TableHead className="font-semibold">Date</TableHead>
|
||||
<TableHead className="font-semibold text-right">Duration</TableHead>
|
||||
<TableHead className="font-semibold text-right">
|
||||
{organizationPricing?.price_per_second_usd ? 'Cost (USD)' : 'Dograh Tokens'}
|
||||
</TableHead>
|
||||
<TableHead className="font-semibold">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{usageHistory.runs.map((run) => (
|
||||
<TableRow
|
||||
key={run.id}
|
||||
>
|
||||
<TableCell
|
||||
className="font-mono text-sm cursor-pointer hover:underline"
|
||||
onClick={() => handleRowClick(run)}
|
||||
>
|
||||
#{run.id}
|
||||
</TableCell>
|
||||
<TableCell>{run.workflow_name || 'Unknown'}</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{run.phone_number || '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{run.disposition ? (
|
||||
<Badge variant={getDispositionBadgeVariant(run.disposition)}>
|
||||
{run.disposition}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{formatDateTime(run.created_at)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatDuration(run.call_duration_seconds)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{organizationPricing?.price_per_second_usd && run.charge_usd !== undefined && run.charge_usd !== null
|
||||
? `$${run.charge_usd.toFixed(2)}`
|
||||
: run.dograh_token_usage.toLocaleString()
|
||||
}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MediaPreviewButtons
|
||||
recordingUrl={run.recording_url}
|
||||
transcriptUrl={run.transcript_url}
|
||||
runId={run.id}
|
||||
onOpenAudio={mediaPreview.openAudioModal}
|
||||
onOpenTranscript={mediaPreview.openTranscriptModal}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{activeFilters.length > 0 && (
|
||||
<div className="mt-4 p-3 bg-gray-50 rounded-md">
|
||||
<p className="text-sm text-gray-600">
|
||||
Total for filtered period: <span className="font-semibold text-gray-900">
|
||||
{usageHistory.total_dograh_tokens.toLocaleString()} Dograh Tokens
|
||||
</span>
|
||||
{' • '}
|
||||
<span className="font-semibold text-gray-900">
|
||||
{formatDuration(usageHistory.total_duration_seconds)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{usageHistory.total_pages > 1 && (
|
||||
<div className="flex items-center justify-between mt-6">
|
||||
<p className="text-sm text-gray-600">
|
||||
Page {usageHistory.page} of {usageHistory.total_pages} ({usageHistory.total_count} total runs)
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === usageHistory.total_pages}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-center py-8 text-gray-500">No usage history found</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Media Preview Dialog */}
|
||||
{mediaPreview.dialog}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
21
ui/src/app/workflow/WorkflowLayout.tsx
Normal file
21
ui/src/app/workflow/WorkflowLayout.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import React, { ReactNode } from 'react'
|
||||
|
||||
import BaseHeader from '@/components/header/BaseHeader'
|
||||
|
||||
interface WorkflowLayoutProps {
|
||||
children: ReactNode,
|
||||
headerActions?: ReactNode,
|
||||
backButton?: ReactNode,
|
||||
showFeaturesNav?: boolean
|
||||
}
|
||||
|
||||
const WorkflowLayout: React.FC<WorkflowLayoutProps> = ({ children, headerActions, backButton, showFeaturesNav = true }) => {
|
||||
return (
|
||||
<>
|
||||
<BaseHeader headerActions={headerActions} backButton={backButton} showFeaturesNav={showFeaturesNav} />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorkflowLayout
|
||||
149
ui/src/app/workflow/[workflowId]/RenderWorkflow.tsx
Normal file
149
ui/src/app/workflow/[workflowId]/RenderWorkflow.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import '@xyflow/react/dist/style.css';
|
||||
|
||||
import {
|
||||
Background,
|
||||
Panel,
|
||||
ReactFlow,
|
||||
} from "@xyflow/react";
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import WorkflowLayout from '@/app/workflow/WorkflowLayout';
|
||||
import { FlowEdge, FlowNode, NodeType } from "@/components/flow/types";
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { WorkflowConfigurations } from '@/types/workflow-configurations';
|
||||
|
||||
import AddNodePanel from "../../../components/flow/AddNodePanel";
|
||||
import CustomEdge from "../../../components/flow/edges/CustomEdge";
|
||||
import { AgentNode, EndCall, GlobalNode, StartCall } from "../../../components/flow/nodes";
|
||||
import WorkflowControls from "./components/WorkflowControls";
|
||||
import WorkflowHeader from "./components/WorkflowHeader";
|
||||
import { WorkflowProvider } from "./contexts/WorkflowContext";
|
||||
import { useWorkflowState } from "./hooks/useWorkflowState";
|
||||
|
||||
// Define the node types dynamically based on the onSave prop
|
||||
const nodeTypes = {
|
||||
[NodeType.START_CALL]: StartCall,
|
||||
[NodeType.AGENT_NODE]: AgentNode,
|
||||
[NodeType.END_CALL]: EndCall,
|
||||
[NodeType.GLOBAL_NODE]: GlobalNode,
|
||||
};
|
||||
|
||||
const edgeTypes = {
|
||||
custom: CustomEdge,
|
||||
};
|
||||
|
||||
interface RenderWorkflowProps {
|
||||
initialWorkflowName: string;
|
||||
workflowId: number;
|
||||
initialFlow?: {
|
||||
nodes: FlowNode[];
|
||||
edges: FlowEdge[];
|
||||
viewport: {
|
||||
x: number;
|
||||
y: number;
|
||||
zoom: number;
|
||||
};
|
||||
};
|
||||
initialTemplateContextVariables?: Record<string, string>;
|
||||
initialWorkflowConfigurations?: WorkflowConfigurations;
|
||||
}
|
||||
|
||||
function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations }: RenderWorkflowProps) {
|
||||
const {
|
||||
rfInstance,
|
||||
nodes,
|
||||
edges,
|
||||
isAddNodePanelOpen,
|
||||
workflowName,
|
||||
isEditingName,
|
||||
isDirty,
|
||||
workflowValidationErrors,
|
||||
templateContextVariables,
|
||||
workflowConfigurations,
|
||||
setNodes,
|
||||
setIsAddNodePanelOpen,
|
||||
setIsEditingName,
|
||||
handleNodeSelect,
|
||||
handleNameChange,
|
||||
saveWorkflow,
|
||||
onConnect,
|
||||
onEdgesChange,
|
||||
onNodesChange,
|
||||
onRun,
|
||||
saveTemplateContextVariables,
|
||||
saveWorkflowConfigurations
|
||||
} = useWorkflowState({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations });
|
||||
|
||||
const backButton = (
|
||||
<Link href="/workflow">
|
||||
<Button variant="outline" size="sm" className="flex items-center gap-1">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Workflows
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
|
||||
const headerActions = (
|
||||
<WorkflowHeader
|
||||
workflowValidationErrors={workflowValidationErrors}
|
||||
isDirty={isDirty}
|
||||
workflowName={workflowName}
|
||||
rfInstance={rfInstance}
|
||||
onRun={onRun}
|
||||
workflowId={workflowId}
|
||||
saveWorkflow={saveWorkflow}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<WorkflowProvider value={{ saveWorkflow }}>
|
||||
<WorkflowLayout headerActions={headerActions} backButton={backButton} showFeaturesNav={false}>
|
||||
<div className="h-[calc(100vh-80px)]">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
onConnect={onConnect}
|
||||
onInit={(instance) => {
|
||||
rfInstance.current = instance;
|
||||
}}
|
||||
defaultEdgeOptions={{ animated: true, type: "custom" }}
|
||||
>
|
||||
<Background />
|
||||
<Panel position="top-left">
|
||||
<WorkflowControls
|
||||
workflowId={workflowId}
|
||||
workflowName={workflowName}
|
||||
isEditingName={isEditingName}
|
||||
setIsEditingName={setIsEditingName}
|
||||
handleNameChange={handleNameChange}
|
||||
setIsAddNodePanelOpen={setIsAddNodePanelOpen}
|
||||
saveWorkflow={saveWorkflow}
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
setNodes={setNodes}
|
||||
rfInstance={rfInstance}
|
||||
templateContextVariables={templateContextVariables}
|
||||
saveTemplateContextVariables={saveTemplateContextVariables}
|
||||
workflowConfigurations={workflowConfigurations}
|
||||
saveWorkflowConfigurations={saveWorkflowConfigurations}
|
||||
/>
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
|
||||
<AddNodePanel
|
||||
isOpen={isAddNodePanelOpen}
|
||||
onNodeSelect={handleNodeSelect}
|
||||
onClose={() => setIsAddNodePanelOpen(false)}
|
||||
/>
|
||||
</WorkflowLayout>
|
||||
</WorkflowProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default RenderWorkflow;
|
||||
|
|
@ -0,0 +1,276 @@
|
|||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { AmbientNoiseConfiguration, VADConfiguration, WorkflowConfigurations } from "@/types/workflow-configurations";
|
||||
|
||||
interface ConfigurationsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
workflowConfigurations: WorkflowConfigurations | null;
|
||||
onSave: (configurations: WorkflowConfigurations) => Promise<void>;
|
||||
}
|
||||
|
||||
const DEFAULT_VAD_CONFIG: VADConfiguration = {
|
||||
confidence: 0.7,
|
||||
start_seconds: 0.4,
|
||||
stop_seconds: 0.8,
|
||||
minimum_volume: 0.6,
|
||||
};
|
||||
|
||||
const DEFAULT_AMBIENT_NOISE_CONFIG: AmbientNoiseConfiguration = {
|
||||
enabled: false,
|
||||
volume: 0.3,
|
||||
};
|
||||
|
||||
export const ConfigurationsDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
workflowConfigurations,
|
||||
onSave
|
||||
}: ConfigurationsDialogProps) => {
|
||||
const [vadConfig, setVadConfig] = useState<VADConfiguration>(
|
||||
workflowConfigurations?.vad_configuration || DEFAULT_VAD_CONFIG
|
||||
);
|
||||
const [ambientNoiseConfig, setAmbientNoiseConfig] = useState<AmbientNoiseConfiguration>(
|
||||
workflowConfigurations?.ambient_noise_configuration || DEFAULT_AMBIENT_NOISE_CONFIG
|
||||
);
|
||||
const [maxCallDuration, setMaxCallDuration] = useState<number>(
|
||||
workflowConfigurations?.max_call_duration || 600 // Default 10 minutes
|
||||
);
|
||||
const [maxUserIdleTimeout, setMaxUserIdleTimeout] = useState<number>(
|
||||
workflowConfigurations?.max_user_idle_timeout || 10 // Default 10 seconds
|
||||
);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSave({
|
||||
vad_configuration: vadConfig,
|
||||
ambient_noise_configuration: ambientNoiseConfig,
|
||||
max_call_duration: maxCallDuration,
|
||||
max_user_idle_timeout: maxUserIdleTimeout
|
||||
});
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to save configurations:", error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDialogOpenChange = (isOpen: boolean) => {
|
||||
onOpenChange(isOpen);
|
||||
if (isOpen) {
|
||||
setVadConfig(workflowConfigurations?.vad_configuration || DEFAULT_VAD_CONFIG);
|
||||
setAmbientNoiseConfig(workflowConfigurations?.ambient_noise_configuration || DEFAULT_AMBIENT_NOISE_CONFIG);
|
||||
setMaxCallDuration(workflowConfigurations?.max_call_duration || 600);
|
||||
setMaxUserIdleTimeout(workflowConfigurations?.max_user_idle_timeout || 10);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVadChange = (field: keyof VADConfiguration, value: string) => {
|
||||
const numValue = parseFloat(value);
|
||||
if (!isNaN(numValue)) {
|
||||
setVadConfig(prev => ({
|
||||
...prev,
|
||||
[field]: numValue
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Configurations</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Voice Activity Detection Section */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-1">Voice Activity Detection</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
Hyperparameters to set for voice activity detection. Already configured with defaults.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confidence" className="text-xs">
|
||||
Confidence
|
||||
</Label>
|
||||
<Input
|
||||
id="confidence"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="1"
|
||||
value={vadConfig.confidence}
|
||||
onChange={(e) => handleVadChange('confidence', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="start_seconds" className="text-xs">
|
||||
Start Seconds
|
||||
</Label>
|
||||
<Input
|
||||
id="start_seconds"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
value={vadConfig.start_seconds}
|
||||
onChange={(e) => handleVadChange('start_seconds', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="stop_seconds" className="text-xs">
|
||||
Stop Seconds
|
||||
</Label>
|
||||
<Input
|
||||
id="stop_seconds"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
value={vadConfig.stop_seconds}
|
||||
onChange={(e) => handleVadChange('stop_seconds', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="minimum_volume" className="text-xs">
|
||||
Minimum Volume
|
||||
</Label>
|
||||
<Input
|
||||
id="minimum_volume"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="1"
|
||||
value={vadConfig.minimum_volume}
|
||||
onChange={(e) => handleVadChange('minimum_volume', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ambient Noise Section */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-1">Ambient Noise</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
Add background office ambient noise to make the conversation sound more natural.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="ambient-noise-enabled" className="text-sm">
|
||||
Use Ambient Noise
|
||||
</Label>
|
||||
<Switch
|
||||
id="ambient-noise-enabled"
|
||||
checked={ambientNoiseConfig.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setAmbientNoiseConfig(prev => ({ ...prev, enabled: checked }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{ambientNoiseConfig.enabled && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ambient-volume" className="text-xs">
|
||||
Volume
|
||||
</Label>
|
||||
<Input
|
||||
id="ambient-volume"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="1"
|
||||
value={ambientNoiseConfig.volume}
|
||||
onChange={(e) => {
|
||||
const value = parseFloat(e.target.value);
|
||||
if (!isNaN(value)) {
|
||||
setAmbientNoiseConfig(prev => ({ ...prev, volume: value }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Call Management Section */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-1">Call Management</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
Configure call duration limits and idle timeout settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max_call_duration" className="text-xs">
|
||||
Max Call Duration (seconds)
|
||||
</Label>
|
||||
<Input
|
||||
id="max_call_duration"
|
||||
type="number"
|
||||
step="1"
|
||||
min="1"
|
||||
value={maxCallDuration}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value);
|
||||
if (!isNaN(value) && value > 0) {
|
||||
setMaxCallDuration(value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">Default: 600 (10 minutes)</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max_user_idle_timeout" className="text-xs">
|
||||
Max User Idle Timeout (seconds)
|
||||
</Label>
|
||||
<Input
|
||||
id="max_user_idle_timeout"
|
||||
type="number"
|
||||
step="1"
|
||||
min="1"
|
||||
value={maxUserIdleTimeout}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value);
|
||||
if (!isNaN(value) && value > 0) {
|
||||
setMaxUserIdleTimeout(value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">Default: 10 seconds</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
import { Trash2Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface TemplateContextVariablesDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
templateContextVariables: Record<string, string>;
|
||||
onSave: (variables: Record<string, string>) => Promise<void>;
|
||||
}
|
||||
|
||||
export const TemplateContextVariablesDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
templateContextVariables,
|
||||
onSave
|
||||
}: TemplateContextVariablesDialogProps) => {
|
||||
const [contextVars, setContextVars] = useState<Record<string, string>>(templateContextVariables);
|
||||
const [newKey, setNewKey] = useState("");
|
||||
const [newValue, setNewValue] = useState("");
|
||||
|
||||
const handleAddContextVar = () => {
|
||||
if (newKey && newValue) {
|
||||
setContextVars(prev => ({ ...prev, [newKey]: newValue }));
|
||||
}
|
||||
setNewKey("");
|
||||
setNewValue("");
|
||||
};
|
||||
|
||||
const handleRemoveContextVar = (key: string) => {
|
||||
setContextVars(prev => {
|
||||
const newVars = { ...prev };
|
||||
delete newVars[key];
|
||||
return newVars;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
let varsToSave = contextVars;
|
||||
// Include any newly typed key/value that hasn't been added via the "Add Variable" button
|
||||
if (newKey && newValue) {
|
||||
varsToSave = { ...varsToSave, [newKey]: newValue };
|
||||
}
|
||||
await onSave(varsToSave);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleDialogOpenChange = (isOpen: boolean) => {
|
||||
onOpenChange(isOpen);
|
||||
if (isOpen) {
|
||||
setContextVars(templateContextVariables);
|
||||
setNewKey("");
|
||||
setNewValue("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Template Context Variables</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{/* Existing Variables */}
|
||||
{Object.entries(contextVars).length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Current Variables</Label>
|
||||
{Object.entries(contextVars).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center gap-2 p-2 border rounded-md">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium">{key}</div>
|
||||
<div className="text-xs text-gray-500 truncate">{value}</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveContextVar(key)}
|
||||
>
|
||||
<Trash2Icon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add New Variable */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">Add New Variable</Label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="key" className="text-xs">Key</Label>
|
||||
<Input
|
||||
id="key"
|
||||
placeholder="Enter variable key"
|
||||
value={newKey}
|
||||
onChange={(e) => setNewKey(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="value" className="text-xs">Value</Label>
|
||||
<Input
|
||||
id="value"
|
||||
placeholder="Enter variable value"
|
||||
value={newValue}
|
||||
onChange={(e) => setNewValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleAddContextVar}
|
||||
disabled={!newKey || !newValue}
|
||||
>
|
||||
Add Variable
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
Save Variables
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
178
ui/src/app/workflow/[workflowId]/components/WorkflowControls.tsx
Normal file
178
ui/src/app/workflow/[workflowId]/components/WorkflowControls.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import dagre from '@dagrejs/dagre';
|
||||
import { ReactFlowInstance } from "@xyflow/react";
|
||||
import { Check, Pencil } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { FlowEdge, FlowNode } from "@/components/flow/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { WorkflowConfigurations } from "@/types/workflow-configurations";
|
||||
|
||||
import { ConfigurationsDialog } from "./ConfigurationsDialog";
|
||||
import { TemplateContextVariablesDialog } from "./TemplateContextVariablesDialog";
|
||||
|
||||
interface WorkflowControlsProps {
|
||||
workflowId: number;
|
||||
workflowName: string;
|
||||
isEditingName: boolean;
|
||||
setIsEditingName: (isEditing: boolean) => void;
|
||||
handleNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
setIsAddNodePanelOpen: (isOpen: boolean) => void;
|
||||
saveWorkflow: (updateWorkflowDefinition: boolean) => Promise<void>;
|
||||
nodes: FlowNode[];
|
||||
edges: FlowEdge[];
|
||||
setNodes: (nodes: FlowNode[] | ((nds: FlowNode[]) => FlowNode[])) => void;
|
||||
rfInstance: React.RefObject<ReactFlowInstance<FlowNode, FlowEdge> | null>;
|
||||
templateContextVariables?: Record<string, string>;
|
||||
saveTemplateContextVariables: (variables: Record<string, string>) => Promise<void>;
|
||||
workflowConfigurations: WorkflowConfigurations | null;
|
||||
saveWorkflowConfigurations: (configurations: WorkflowConfigurations) => Promise<void>;
|
||||
}
|
||||
|
||||
export const layoutNodes = (
|
||||
nodes: FlowNode[],
|
||||
edges: FlowEdge[],
|
||||
rankdir: 'TB' | 'LR',
|
||||
rfInstance: React.RefObject<ReactFlowInstance<FlowNode, FlowEdge> | null>,
|
||||
saveWorkflow: (updateWorkflowDefinition: boolean) => Promise<void>
|
||||
) => {
|
||||
const g = new dagre.graphlib.Graph();
|
||||
g.setGraph({ rankdir, nodesep: 250, ranksep: 250 });
|
||||
g.setDefaultEdgeLabel(() => ({}));
|
||||
|
||||
// Sort nodes so startCall nodes come first and endCall nodes come last
|
||||
const sortedNodes = [...nodes].sort((a, b) => {
|
||||
if (a.type === 'startCall') return -1;
|
||||
if (b.type === 'startCall') return 1;
|
||||
if (a.type === 'endCall') return 1;
|
||||
if (b.type === 'endCall') return -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
sortedNodes.forEach((node) => {
|
||||
g.setNode(node.id, { width: 180, height: 60 });
|
||||
});
|
||||
|
||||
edges.forEach((edge) => {
|
||||
g.setEdge(edge.source, edge.target);
|
||||
});
|
||||
|
||||
dagre.layout(g);
|
||||
|
||||
const newNodes = sortedNodes.map((node) => {
|
||||
const nodeWithPosition = g.node(node.id);
|
||||
return {
|
||||
...node,
|
||||
position: { x: nodeWithPosition.x, y: nodeWithPosition.y }
|
||||
};
|
||||
});
|
||||
|
||||
// Fit view to the new layout and save the viewport position
|
||||
setTimeout(() => {
|
||||
rfInstance.current?.fitView();
|
||||
saveWorkflow(true);
|
||||
}, 0);
|
||||
|
||||
return newNodes;
|
||||
};
|
||||
|
||||
const WorkflowControls = ({
|
||||
workflowId,
|
||||
workflowName,
|
||||
isEditingName,
|
||||
setIsEditingName,
|
||||
handleNameChange,
|
||||
setIsAddNodePanelOpen,
|
||||
saveWorkflow,
|
||||
nodes,
|
||||
edges,
|
||||
setNodes,
|
||||
rfInstance,
|
||||
templateContextVariables = {},
|
||||
saveTemplateContextVariables,
|
||||
workflowConfigurations,
|
||||
saveWorkflowConfigurations
|
||||
}: WorkflowControlsProps) => {
|
||||
const router = useRouter();
|
||||
const [isContextVarsDialogOpen, setIsContextVarsDialogOpen] = useState(false);
|
||||
const [isConfigurationsDialogOpen, setIsConfigurationsDialogOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center relative bg-white border border-gray-200 rounded-md px-3 py-1 shadow-sm group hover:border-gray-300 transition-colors w-45">
|
||||
{isEditingName ? (
|
||||
<input
|
||||
type="text"
|
||||
value={workflowName}
|
||||
onChange={handleNameChange}
|
||||
className="pr-8 bg-transparent focus:outline-none w-full text-lg"
|
||||
autoFocus
|
||||
onKeyDown={(e) => e.key === 'Enter' && (setIsEditingName(false), saveWorkflow(false))}
|
||||
/>
|
||||
) : (
|
||||
<h1 className="text-lg font-medium pr-8 truncate">{workflowName}</h1>
|
||||
)}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
if (isEditingName) {
|
||||
setIsEditingName(false);
|
||||
saveWorkflow(false);
|
||||
} else {
|
||||
setIsEditingName(true);
|
||||
}
|
||||
}}
|
||||
className="h-7 w-7 absolute right-2 top-1/2 transform -translate-y-1/2"
|
||||
>
|
||||
{isEditingName ? (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Pencil className="h-4 w-4 opacity-50 group-hover:opacity-100 transition-opacity" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button onClick={() => setIsAddNodePanelOpen(true)}>Add New Node</Button>
|
||||
<Button onClick={() => setNodes(layoutNodes(nodes, edges, 'TB', rfInstance, saveWorkflow))}>Vertical Layout</Button>
|
||||
<Button onClick={() => setNodes(layoutNodes(nodes, edges, 'LR', rfInstance, saveWorkflow))}>Horizontal Layout</Button>
|
||||
<Button
|
||||
onClick={() => setIsConfigurationsDialogOpen(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Configurations
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setIsContextVarsDialogOpen(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Template Context Variables
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => router.push(`/workflow/${workflowId}/runs`)}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
View Run History
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ConfigurationsDialog
|
||||
open={isConfigurationsDialogOpen}
|
||||
onOpenChange={setIsConfigurationsDialogOpen}
|
||||
workflowConfigurations={workflowConfigurations}
|
||||
onSave={saveWorkflowConfigurations}
|
||||
/>
|
||||
|
||||
<TemplateContextVariablesDialog
|
||||
open={isContextVarsDialogOpen}
|
||||
onOpenChange={setIsContextVarsDialogOpen}
|
||||
templateContextVariables={templateContextVariables}
|
||||
onSave={saveTemplateContextVariables}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkflowControls;
|
||||
321
ui/src/app/workflow/[workflowId]/components/WorkflowHeader.tsx
Normal file
321
ui/src/app/workflow/[workflowId]/components/WorkflowHeader.tsx
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
import 'react-international-phone/style.css';
|
||||
|
||||
import { ReactFlowInstance, ReactFlowJsonObject } from "@xyflow/react";
|
||||
import { AlertTriangle, CheckCheck, Download, LoaderCircle, Phone, ShieldCheck } from "lucide-react";
|
||||
import { useEffect,useState } from "react";
|
||||
import { PhoneInput } from 'react-international-phone';
|
||||
|
||||
import { initiateCallApiV1TwilioInitiateCallPost } from '@/client/sdk.gen';
|
||||
import { WorkflowError } from '@/client/types.gen';
|
||||
import { FlowEdge, FlowNode } from "@/components/flow/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useUserConfig } from "@/context/UserConfigContext";
|
||||
import { useAuth } from '@/lib/auth';
|
||||
|
||||
interface WorkflowHeaderProps {
|
||||
isDirty: boolean;
|
||||
workflowName: string;
|
||||
rfInstance: React.RefObject<ReactFlowInstance<FlowNode, FlowEdge> | null>;
|
||||
onRun: (mode: string) => Promise<void>;
|
||||
workflowId: number;
|
||||
workflowValidationErrors: WorkflowError[];
|
||||
saveWorkflow: (updateWorkflowDefinition?: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
const handleExport = (workflow_name: string, workflow_definition: ReactFlowJsonObject<FlowNode, FlowEdge> | undefined) => {
|
||||
if (!workflow_definition) return { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } };
|
||||
|
||||
const exportData = {
|
||||
name: workflow_name,
|
||||
workflow_definition: workflow_definition
|
||||
};
|
||||
|
||||
// Convert to JSON string with proper formatting
|
||||
const jsonString = JSON.stringify(exportData, null, 2);
|
||||
|
||||
// Create a blob with the JSON data
|
||||
const blob = new Blob([jsonString], { type: 'application/json' });
|
||||
|
||||
// Create a download link
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${workflow_name.replace(/\s+/g, '_')}.json`;
|
||||
|
||||
// Trigger download
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
// Cleanup
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId, workflowValidationErrors, saveWorkflow }: WorkflowHeaderProps) => {
|
||||
const { userConfig, saveUserConfig } = useUserConfig();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [phoneNumber, setPhoneNumber] = useState(userConfig?.test_phone_number || "");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savingWorkflow, setSavingWorkflow] = useState(false);
|
||||
const [callLoading, setCallLoading] = useState(false);
|
||||
const [callError, setCallError] = useState<string | null>(null);
|
||||
const [callSuccessMsg, setCallSuccessMsg] = useState<string | null>(null);
|
||||
const [phoneChanged, setPhoneChanged] = useState(false);
|
||||
const [validationDialogOpen, setValidationDialogOpen] = useState(false);
|
||||
const { user, getAccessToken } = useAuth();
|
||||
|
||||
const hasValidationErrors = workflowValidationErrors.length > 0;
|
||||
|
||||
// Reset call-related state whenever the dialog is closed so that a new call can be placed
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) {
|
||||
setCallError(null);
|
||||
setCallSuccessMsg(null);
|
||||
setCallLoading(false);
|
||||
}
|
||||
}, [dialogOpen]);
|
||||
|
||||
// Keep phoneNumber in sync with userConfig when dialog opens
|
||||
const handleDialogOpenChange = (open: boolean) => {
|
||||
setDialogOpen(open);
|
||||
if (open) {
|
||||
setPhoneNumber(userConfig?.test_phone_number || "");
|
||||
setPhoneChanged(false);
|
||||
setCallError(null);
|
||||
setCallSuccessMsg(null);
|
||||
setCallLoading(false);
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePhoneInputChange = (
|
||||
formattedValue: string
|
||||
) => {
|
||||
// `value` is the raw E.164 value, e.g. "+14155552671"
|
||||
setPhoneNumber(formattedValue);
|
||||
setPhoneChanged(formattedValue !== userConfig?.test_phone_number);
|
||||
|
||||
// clear any prior errors, etc.
|
||||
setCallError(null);
|
||||
setCallSuccessMsg(null);
|
||||
};
|
||||
|
||||
|
||||
const handleSavePhone = async () => {
|
||||
if (!userConfig) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await saveUserConfig({ ...userConfig, test_phone_number: phoneNumber });
|
||||
setPhoneChanged(false);
|
||||
} catch (err: unknown) {
|
||||
setCallError(err instanceof Error ? err.message : "Failed to save phone number");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartCall = async () => {
|
||||
setCallLoading(true);
|
||||
setCallError(null);
|
||||
setCallSuccessMsg(null);
|
||||
try {
|
||||
if (!user) return;
|
||||
const accessToken = await getAccessToken();
|
||||
const response = await initiateCallApiV1TwilioInitiateCallPost({
|
||||
body: { workflow_id: workflowId },
|
||||
headers: { 'Authorization': `Bearer ${accessToken}` },
|
||||
});
|
||||
if (response.error) {
|
||||
let errMsg = "Failed to initiate call";
|
||||
if (typeof response.error === "string") {
|
||||
errMsg = response.error;
|
||||
} else if (response.error && typeof response.error === "object") {
|
||||
errMsg = (response.error as unknown as { detail: string }).detail || JSON.stringify(response.error);
|
||||
}
|
||||
setCallError(errMsg);
|
||||
} else {
|
||||
// Try to show a message from the response, fallback to generic
|
||||
const msg = response.data && (response.data as unknown as { message: string }).message || "Call initiated successfully!";
|
||||
setCallSuccessMsg(typeof msg === "string" ? msg : JSON.stringify(msg));
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setCallError(err instanceof Error ? err.message : "Failed to initiate call");
|
||||
} finally {
|
||||
setCallLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1 text-sm text-gray-500 mr-2">
|
||||
{hasValidationErrors ? (
|
||||
<AlertTriangle className="h-4 w-4 text-red-500" />
|
||||
) : (
|
||||
<ShieldCheck className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
<span>{hasValidationErrors ? 'Invalid' : 'Valid'}</span>
|
||||
{hasValidationErrors && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="ml-1 h-6 px-2 text-xs"
|
||||
onClick={() => setValidationDialogOpen(true)}
|
||||
>
|
||||
View Issues
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{hasValidationErrors
|
||||
? `Workflow has ${workflowValidationErrors.length} validation ${workflowValidationErrors.length === 1 ? 'issue' : 'issues'}`
|
||||
: 'Workflow is valid'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleExport(workflowName, rfInstance.current?.toObject())}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export Pathway
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onRun("smallwebrtc")} // Don't change the mode since its defined in the database enum
|
||||
disabled={hasValidationErrors}
|
||||
>
|
||||
<Phone className="mr-2 h-4 w-4" />
|
||||
Web Call
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
disabled={hasValidationErrors}
|
||||
>
|
||||
<Phone className="mr-2 h-4 w-4" />
|
||||
Phone Call
|
||||
</Button>
|
||||
|
||||
{isDirty ? (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
setSavingWorkflow(true);
|
||||
await saveWorkflow();
|
||||
setSavingWorkflow(false);
|
||||
}}
|
||||
disabled={savingWorkflow}
|
||||
className="animate-pulse"
|
||||
>
|
||||
{savingWorkflow ? (
|
||||
<>
|
||||
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Save Changes'
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 text-sm text-gray-500">
|
||||
<CheckCheck className="h-4 w-4 text-green-500" />
|
||||
<span className='mr-2'>Saved</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Validation Errors Dialog */}
|
||||
<Dialog open={validationDialogOpen} onOpenChange={setValidationDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Workflow Validation Issues</DialogTitle>
|
||||
<DialogDescription>
|
||||
Please fix the following issues before running the workflow.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[60vh] overflow-y-auto">
|
||||
<ul className="space-y-2">
|
||||
{workflowValidationErrors.map((error, index) => (
|
||||
<li key={index} className="border-l-2 border-red-500 pl-3 py-2">
|
||||
<div className="font-medium">{error.message}</div>
|
||||
{error.id && (
|
||||
<div className="text-sm text-gray-500">
|
||||
{error.kind === 'node' ? 'Node' : error.kind === 'edge' ? 'Edge' : 'Workflow'} ID: {error.id}
|
||||
</div>
|
||||
)}
|
||||
{error.field && (
|
||||
<div className="text-sm mt-1">
|
||||
Field: {error.field}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setValidationDialogOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Phone Call Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={handleDialogOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Phone Call</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter the phone number to call. This will be saved to your user config.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<PhoneInput
|
||||
defaultCountry="in"
|
||||
value={phoneNumber}
|
||||
onChange={handlePhoneInputChange}
|
||||
/>
|
||||
{phoneChanged && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSavePhone}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? "Saving..." : "Save Number"}
|
||||
</Button>
|
||||
)}
|
||||
<DialogFooter>
|
||||
{!callSuccessMsg ? (
|
||||
<Button
|
||||
onClick={handleStartCall}
|
||||
disabled={callLoading || phoneChanged || !phoneNumber || saving}
|
||||
>
|
||||
{callLoading ? "Calling..." : "Start Call"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => setDialogOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
)}
|
||||
<DialogClose asChild>
|
||||
<Button variant="ghost">Cancel</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
{callError && <div className="text-red-500 text-sm mt-2">{callError}</div>}
|
||||
{callSuccessMsg && <div className="text-green-600 text-sm mt-2">{callSuccessMsg}</div>}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkflowHeader;
|
||||
2
ui/src/app/workflow/[workflowId]/components/index.ts
Normal file
2
ui/src/app/workflow/[workflowId]/components/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './WorkflowControls';
|
||||
export * from './WorkflowHeader';
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { createContext, useContext } from 'react';
|
||||
|
||||
interface WorkflowContextType {
|
||||
saveWorkflow: (updateWorkflowDefinition?: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
const WorkflowContext = createContext<WorkflowContextType | undefined>(undefined);
|
||||
|
||||
export const WorkflowProvider = WorkflowContext.Provider;
|
||||
|
||||
export const useWorkflow = () => {
|
||||
const context = useContext(WorkflowContext);
|
||||
if (!context) {
|
||||
throw new Error('useWorkflow must be used within a WorkflowProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
1
ui/src/app/workflow/[workflowId]/hooks/index.ts
Normal file
1
ui/src/app/workflow/[workflowId]/hooks/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './useWorkflowState';
|
||||
446
ui/src/app/workflow/[workflowId]/hooks/useWorkflowState.ts
Normal file
446
ui/src/app/workflow/[workflowId]/hooks/useWorkflowState.ts
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
import {
|
||||
applyEdgeChanges,
|
||||
applyNodeChanges,
|
||||
OnConnect,
|
||||
OnEdgesChange,
|
||||
OnNodesChange,
|
||||
ReactFlowInstance,
|
||||
useEdgesState,
|
||||
useNodesState
|
||||
} from "@xyflow/react";
|
||||
import { addEdge } from "@xyflow/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
createWorkflowRunApiV1WorkflowWorkflowIdRunsPost,
|
||||
updateWorkflowApiV1WorkflowWorkflowIdPut,
|
||||
validateWorkflowApiV1WorkflowWorkflowIdValidatePost
|
||||
} from "@/client";
|
||||
import { WorkflowError } from "@/client/types.gen";
|
||||
import { FlowEdge, FlowNode, NodeType } from "@/components/flow/types";
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import logger from '@/lib/logger';
|
||||
import { getRandomId } from "@/lib/utils";
|
||||
import { DEFAULT_WORKFLOW_CONFIGURATIONS,WorkflowConfigurations } from "@/types/workflow-configurations";
|
||||
|
||||
export function getDefaultAllowInterrupt(type: string = NodeType.START_CALL): boolean {
|
||||
switch (type) {
|
||||
case NodeType.AGENT_NODE:
|
||||
return true; // Agents can be interrupted
|
||||
case NodeType.START_CALL:
|
||||
case NodeType.END_CALL:
|
||||
return false; // Start/End messages should not be interrupted
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const defaultNodes: FlowNode[] = [
|
||||
{
|
||||
id: "1",
|
||||
type: NodeType.START_CALL,
|
||||
position: { x: 200, y: 200 },
|
||||
data: {
|
||||
prompt: "",
|
||||
name: "",
|
||||
allow_interrupt: getDefaultAllowInterrupt(NodeType.START_CALL),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const getNewNode = (type: string, position: { x: number, y: number }) => {
|
||||
return {
|
||||
id: `${getRandomId()}`,
|
||||
type,
|
||||
position,
|
||||
data: {
|
||||
prompt: {
|
||||
[NodeType.GLOBAL_NODE]: "You are a helpful assistant whose mode of interaction with the user is voice. So don't use any special characters which can not be pronounced. Use short sentences and simple language.",
|
||||
}[type] || "",
|
||||
name: {
|
||||
[NodeType.GLOBAL_NODE]: "Global Node",
|
||||
[NodeType.START_CALL]: "Start Call",
|
||||
[NodeType.END_CALL]: "End Call",
|
||||
}[type] || "",
|
||||
allow_interrupt: getDefaultAllowInterrupt(type),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
interface UseWorkflowStateProps {
|
||||
initialWorkflowName: string;
|
||||
workflowId: number;
|
||||
initialFlow?: {
|
||||
nodes: FlowNode[];
|
||||
edges: FlowEdge[];
|
||||
viewport: {
|
||||
x: number;
|
||||
y: number;
|
||||
zoom: number;
|
||||
};
|
||||
};
|
||||
initialTemplateContextVariables?: Record<string, string>;
|
||||
initialWorkflowConfigurations?: WorkflowConfigurations;
|
||||
}
|
||||
|
||||
export const useWorkflowState = ({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations }: UseWorkflowStateProps) => {
|
||||
const rfInstance = useRef<ReactFlowInstance<FlowNode, FlowEdge> | null>(null);
|
||||
const router = useRouter();
|
||||
const { user, getAccessToken } = useAuth();
|
||||
const [nodes, setNodes] = useNodesState(
|
||||
initialFlow?.nodes?.length
|
||||
? initialFlow.nodes.map(node => ({
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
invalid: false,
|
||||
allow_interrupt: node.data.allow_interrupt !== undefined
|
||||
? node.data.allow_interrupt
|
||||
: getDefaultAllowInterrupt(node.type),
|
||||
}
|
||||
}))
|
||||
: defaultNodes
|
||||
);
|
||||
const [edges, setEdges] = useEdgesState(initialFlow?.edges ?? []);
|
||||
const [isAddNodePanelOpen, setIsAddNodePanelOpen] = useState(false);
|
||||
const [workflowName, setWorkflowName] = useState(initialWorkflowName);
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [workflowValidationErrors, setWorkflowValidationErrors] = useState<WorkflowError[]>([]);
|
||||
const [templateContextVariables, setTemplateContextVariables] = useState<Record<string, string>>(
|
||||
initialTemplateContextVariables || {}
|
||||
);
|
||||
const [workflowConfigurations, setWorkflowConfigurations] = useState<WorkflowConfigurations | null>(
|
||||
initialWorkflowConfigurations || DEFAULT_WORKFLOW_CONFIGURATIONS
|
||||
);
|
||||
|
||||
const handleNodeSelect = useCallback((nodeType: string) => {
|
||||
/*
|
||||
Used to add new node to the workflow. Receives nodeType as param.
|
||||
Example: nodeType can be agentNode/ startNode etc. as defined by NodeType in
|
||||
types.ts
|
||||
|
||||
We then pass nodeTypes which contais the NodeType keyword and the component.
|
||||
Those components then contain all the component speecific functioanlity like edit
|
||||
button etc.
|
||||
|
||||
*/
|
||||
const newNode = getNewNode(nodeType, { x: 150, y: 150 });
|
||||
setNodes((nds) => [...nds, newNode]);
|
||||
setIsAddNodePanelOpen(false);
|
||||
}, [setNodes, setIsAddNodePanelOpen]);
|
||||
|
||||
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setWorkflowName(e.target.value);
|
||||
setIsDirty(true);
|
||||
};
|
||||
|
||||
// Validate workflow function (without saving)
|
||||
const validateWorkflow = useCallback(async () => {
|
||||
if (!user) return;
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
const response = await validateWorkflowApiV1WorkflowWorkflowIdValidatePost({
|
||||
path: {
|
||||
workflow_id: workflowId,
|
||||
},
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
// Reset validation state for all nodes and edges
|
||||
setNodes((nds) => nds.map(node => ({ ...node, data: { ...node.data, invalid: false, validationMessage: null } })));
|
||||
setEdges((eds) => eds.map(edge => ({ ...edge, data: { ...edge.data, invalid: false, validationMessage: null } })));
|
||||
setWorkflowValidationErrors([]);
|
||||
|
||||
// Check if we have a 422 error with validation errors
|
||||
if (response.error) {
|
||||
// The error could be in different formats depending on the status code
|
||||
let errors: WorkflowError[] = [];
|
||||
|
||||
// Type assertion for validation response structure
|
||||
const errorResponse = response.error as {
|
||||
is_valid?: boolean;
|
||||
errors?: WorkflowError[];
|
||||
detail?: { errors: WorkflowError[] };
|
||||
};
|
||||
|
||||
// For 422 responses, the error contains the validation response
|
||||
if (errorResponse.is_valid === false && errorResponse.errors) {
|
||||
errors = errorResponse.errors;
|
||||
}
|
||||
// Also check for detail.errors format
|
||||
else if (errorResponse.detail?.errors) {
|
||||
errors = errorResponse.detail.errors;
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
// Update nodes with validation state
|
||||
setNodes((nds) => nds.map(node => {
|
||||
const nodeErrors = errors.filter((err) => err.kind === 'node' && err.id === node.id);
|
||||
if (nodeErrors.length > 0) {
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
invalid: true,
|
||||
validationMessage: nodeErrors.map(err => err.message).join(', ')
|
||||
}
|
||||
};
|
||||
}
|
||||
return node;
|
||||
}));
|
||||
|
||||
// Update edges with validation state
|
||||
setEdges((eds) => eds.map(edge => {
|
||||
const edgeErrors = errors.filter((err) => err.kind === 'edge' && err.id === edge.id);
|
||||
if (edgeErrors.length > 0) {
|
||||
return {
|
||||
...edge,
|
||||
data: {
|
||||
...edge.data,
|
||||
invalid: true,
|
||||
validationMessage: edgeErrors.map(err => err.message).join(', ')
|
||||
}
|
||||
};
|
||||
}
|
||||
return edge;
|
||||
}));
|
||||
|
||||
// Set workflow validation errors (all types of errors)
|
||||
setWorkflowValidationErrors(errors);
|
||||
}
|
||||
} else if (response.data) {
|
||||
// If we get a 200 response with data, check if it's valid
|
||||
if (response.data.is_valid === false && response.data.errors) {
|
||||
const errors = response.data.errors;
|
||||
|
||||
// Update nodes with validation state
|
||||
setNodes((nds) => nds.map(node => {
|
||||
const nodeErrors = errors.filter((err) => err.kind === 'node' && err.id === node.id);
|
||||
if (nodeErrors.length > 0) {
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
invalid: true,
|
||||
validationMessage: nodeErrors.map((err) => err.message).join(', ')
|
||||
}
|
||||
};
|
||||
}
|
||||
return node;
|
||||
}));
|
||||
|
||||
// Update edges with validation state
|
||||
setEdges((eds) => eds.map(edge => {
|
||||
const edgeErrors = errors.filter((err) => err.kind === 'edge' && err.id === edge.id);
|
||||
if (edgeErrors.length > 0) {
|
||||
return {
|
||||
...edge,
|
||||
data: {
|
||||
...edge.data,
|
||||
invalid: true,
|
||||
validationMessage: edgeErrors.map((err) => err.message).join(', ')
|
||||
}
|
||||
};
|
||||
}
|
||||
return edge;
|
||||
}));
|
||||
|
||||
// Set workflow validation errors (all types of errors)
|
||||
setWorkflowValidationErrors(errors);
|
||||
} else {
|
||||
logger.info('Workflow is valid');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Unexpected validation error: ${error}`);
|
||||
}
|
||||
}, [workflowId, user, getAccessToken, setNodes, setEdges]);
|
||||
|
||||
// Save function
|
||||
const saveWorkflow = useCallback(async (updateWorkflowDefinition: boolean = true) => {
|
||||
/*
|
||||
validates and saves workflow
|
||||
*/
|
||||
if (!user || !rfInstance.current) return;
|
||||
const flow = rfInstance.current.toObject();
|
||||
const accessToken = await getAccessToken();
|
||||
try {
|
||||
await updateWorkflowApiV1WorkflowWorkflowIdPut({
|
||||
path: {
|
||||
workflow_id: workflowId,
|
||||
},
|
||||
body: {
|
||||
name: workflowName,
|
||||
workflow_definition: updateWorkflowDefinition ? flow : null,
|
||||
},
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
setIsDirty(false);
|
||||
} catch (error) {
|
||||
logger.error(`Error auto-saving workflow: ${error}`);
|
||||
}
|
||||
|
||||
// Validate after saving
|
||||
await validateWorkflow();
|
||||
}, [workflowId, workflowName, setIsDirty, user, getAccessToken, rfInstance, validateWorkflow]);
|
||||
|
||||
// Handle debounced save - REMOVED AUTOSAVE FUNCTIONALITY
|
||||
// const debouncedSave = useCallback(() => {
|
||||
// // Clear any existing timeout
|
||||
// if (saveTimeoutRef.current) {
|
||||
// clearTimeout(saveTimeoutRef.current);
|
||||
// }
|
||||
|
||||
// // Set a new timeout
|
||||
// saveTimeoutRef.current = setTimeout(() => {
|
||||
// saveWorkflow();
|
||||
// saveTimeoutRef.current = null;
|
||||
// }, 2000);
|
||||
// }, [saveWorkflow]);
|
||||
|
||||
const onConnect: OnConnect = useCallback((connection) => {
|
||||
setEdges((eds) => addEdge({
|
||||
...connection,
|
||||
data: {
|
||||
label: '',
|
||||
condition: ''
|
||||
}
|
||||
}, eds));
|
||||
setIsDirty(true);
|
||||
// Trigger validation after connection
|
||||
setTimeout(() => validateWorkflow(), 100);
|
||||
}, [setEdges, validateWorkflow]);
|
||||
|
||||
const onEdgesChange: OnEdgesChange = useCallback(
|
||||
(changes) => setEdges((eds) => {
|
||||
const newEdges = applyEdgeChanges(changes, eds) as FlowEdge[];
|
||||
setIsDirty(true);
|
||||
// Trigger validation after edge changes
|
||||
setTimeout(() => validateWorkflow(), 100);
|
||||
return newEdges;
|
||||
}),
|
||||
[setEdges, validateWorkflow],
|
||||
);
|
||||
|
||||
const onNodesChange: OnNodesChange = useCallback(
|
||||
(changes) => setNodes((nds) => {
|
||||
const newNodes = applyNodeChanges(changes, nds) as FlowNode[];
|
||||
setIsDirty(true);
|
||||
// Trigger validation after node changes
|
||||
setTimeout(() => validateWorkflow(), 100);
|
||||
return newNodes;
|
||||
}),
|
||||
[setNodes, validateWorkflow],
|
||||
);
|
||||
|
||||
const onRun = async (mode: string) => {
|
||||
if (!user) return;
|
||||
const workflowRunName = `WR-${getRandomId()}`;
|
||||
const accessToken = await getAccessToken();
|
||||
const response = await createWorkflowRunApiV1WorkflowWorkflowIdRunsPost({
|
||||
path: {
|
||||
workflow_id: workflowId,
|
||||
},
|
||||
body: {
|
||||
mode,
|
||||
name: workflowRunName
|
||||
},
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
router.push(`/workflow/${workflowId}/run/${response.data?.id}`);
|
||||
};
|
||||
|
||||
// Save template context variables function
|
||||
const saveTemplateContextVariables = useCallback(async (variables: Record<string, string>) => {
|
||||
if (!user) return;
|
||||
const accessToken = await getAccessToken();
|
||||
try {
|
||||
await updateWorkflowApiV1WorkflowWorkflowIdPut({
|
||||
path: {
|
||||
workflow_id: workflowId,
|
||||
},
|
||||
body: {
|
||||
name: workflowName,
|
||||
workflow_definition: null,
|
||||
template_context_variables: variables,
|
||||
},
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
setTemplateContextVariables(variables);
|
||||
logger.info('Template context variables saved successfully');
|
||||
} catch (error) {
|
||||
logger.error(`Error saving template context variables: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}, [workflowId, workflowName, user, getAccessToken]);
|
||||
|
||||
// Save workflow configurations function
|
||||
const saveWorkflowConfigurations = useCallback(async (configurations: WorkflowConfigurations) => {
|
||||
if (!user) return;
|
||||
const accessToken = await getAccessToken();
|
||||
try {
|
||||
await updateWorkflowApiV1WorkflowWorkflowIdPut({
|
||||
path: {
|
||||
workflow_id: workflowId,
|
||||
},
|
||||
body: {
|
||||
name: workflowName,
|
||||
workflow_definition: null,
|
||||
workflow_configurations: configurations as Record<string, unknown>,
|
||||
},
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
setWorkflowConfigurations(configurations);
|
||||
logger.info('Workflow configurations saved successfully');
|
||||
} catch (error) {
|
||||
logger.error(`Error saving workflow configurations: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}, [workflowId, workflowName, user, getAccessToken]);
|
||||
|
||||
// Validate workflow on mount
|
||||
useEffect(() => {
|
||||
validateWorkflow();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Removed useEffect for clearing auto-save timeout as autosave is disabled
|
||||
|
||||
return {
|
||||
rfInstance,
|
||||
nodes,
|
||||
edges,
|
||||
isAddNodePanelOpen,
|
||||
workflowName,
|
||||
isEditingName,
|
||||
isDirty,
|
||||
workflowValidationErrors,
|
||||
templateContextVariables,
|
||||
workflowConfigurations,
|
||||
setNodes,
|
||||
setEdges,
|
||||
setIsAddNodePanelOpen,
|
||||
setWorkflowName,
|
||||
setIsEditingName,
|
||||
handleNodeSelect,
|
||||
handleNameChange,
|
||||
saveWorkflow,
|
||||
onConnect,
|
||||
onEdgesChange,
|
||||
onNodesChange,
|
||||
onRun,
|
||||
saveTemplateContextVariables,
|
||||
saveWorkflowConfigurations
|
||||
};
|
||||
};
|
||||
91
ui/src/app/workflow/[workflowId]/page.tsx
Normal file
91
ui/src/app/workflow/[workflowId]/page.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
'use client';
|
||||
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import RenderWorkflow from '@/app/workflow/[workflowId]/RenderWorkflow';
|
||||
import { getWorkflowApiV1WorkflowFetchWorkflowIdGet } from '@/client/sdk.gen';
|
||||
import type { WorkflowResponse } from '@/client/types.gen';
|
||||
import { FlowEdge, FlowNode } from '@/components/flow/types';
|
||||
import SpinLoader from '@/components/SpinLoader';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import logger from '@/lib/logger';
|
||||
import { DEFAULT_WORKFLOW_CONFIGURATIONS,WorkflowConfigurations } from '@/types/workflow-configurations';
|
||||
|
||||
import WorkflowLayout from '../WorkflowLayout';
|
||||
|
||||
export default function WorkflowDetailPage() {
|
||||
const params = useParams();
|
||||
const [workflow, setWorkflow] = useState<WorkflowResponse | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { user, getAccessToken, redirectToLogin, loading: authLoading } = useAuth();
|
||||
|
||||
// Redirect if not authenticated
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
redirectToLogin();
|
||||
}
|
||||
}, [authLoading, user, redirectToLogin]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchWorkflow = async () => {
|
||||
if (!user) return;
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
const response = await getWorkflowApiV1WorkflowFetchWorkflowIdGet({
|
||||
path: {
|
||||
workflow_id: Number(params.workflowId)
|
||||
},
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
const workflow = response.data;
|
||||
setWorkflow(workflow);
|
||||
} catch (err) {
|
||||
setError('Failed to fetch workflow');
|
||||
logger.error(`Error fetching workflow: ${err}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (user) {
|
||||
fetchWorkflow();
|
||||
}
|
||||
}, [params.workflowId, user, getAccessToken]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<WorkflowLayout>
|
||||
<SpinLoader />
|
||||
</WorkflowLayout>
|
||||
);
|
||||
}
|
||||
else if (error || !workflow) {
|
||||
return (
|
||||
<WorkflowLayout showFeaturesNav={false}>
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-lg text-red-500">{error || 'Workflow not found'}</div>
|
||||
</div>
|
||||
</WorkflowLayout>
|
||||
);
|
||||
}
|
||||
else {
|
||||
return (
|
||||
// We are sending custom header actions to WorkflowLayout from RenderWorkflow component
|
||||
<RenderWorkflow
|
||||
initialWorkflowName={workflow.name}
|
||||
workflowId={workflow.id}
|
||||
initialFlow={{
|
||||
nodes: workflow.workflow_definition.nodes as FlowNode[],
|
||||
edges: workflow.workflow_definition.edges as FlowEdge[],
|
||||
viewport: { x: 0, y: 0, zoom: 1 }
|
||||
}}
|
||||
initialTemplateContextVariables={workflow.template_context_variables as Record<string, string> || {}}
|
||||
initialWorkflowConfigurations={(workflow.workflow_configurations as WorkflowConfigurations) || DEFAULT_WORKFLOW_CONFIGURATIONS}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
114
ui/src/app/workflow/[workflowId]/run/[runId]/Pipecat.tsx
Normal file
114
ui/src/app/workflow/[workflowId]/run/[runId]/Pipecat.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
import {
|
||||
ApiKeyErrorDialog,
|
||||
AudioControls,
|
||||
ConnectionStatus,
|
||||
ContextVariablesSection,
|
||||
WorkflowConfigErrorDialog
|
||||
} from "./components";
|
||||
import { useWebRTC } from "./hooks";
|
||||
|
||||
const Pipecat = ({ workflowId, workflowRunId, accessToken, initialContextVariables }: {
|
||||
workflowId: number,
|
||||
workflowRunId: number,
|
||||
accessToken: string | null,
|
||||
initialContextVariables?: Record<string, string> | null
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
audioRef,
|
||||
audioInputs,
|
||||
selectedAudioInput,
|
||||
setSelectedAudioInput,
|
||||
connectionActive,
|
||||
permissionError,
|
||||
isCompleted,
|
||||
apiKeyModalOpen,
|
||||
setApiKeyModalOpen,
|
||||
apiKeyError,
|
||||
workflowConfigError,
|
||||
workflowConfigModalOpen,
|
||||
setWorkflowConfigModalOpen,
|
||||
iceGatheringState,
|
||||
iceConnectionState,
|
||||
start,
|
||||
stop,
|
||||
isStarting,
|
||||
initialContext,
|
||||
setInitialContext
|
||||
} = useWebRTC({ workflowId, workflowRunId, accessToken, initialContextVariables });
|
||||
|
||||
const navigateToApiKeys = () => {
|
||||
router.push('/api-keys');
|
||||
};
|
||||
|
||||
const navigateToWorkflow = () => {
|
||||
router.push(`/workflow/${workflowId}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="w-full max-w-4xl mx-auto">
|
||||
<CardHeader>
|
||||
<CardTitle>Workflow Run</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4">
|
||||
<>
|
||||
<ContextVariablesSection
|
||||
initialContext={initialContext}
|
||||
setInitialContext={setInitialContext}
|
||||
disabled={connectionActive || isCompleted}
|
||||
/>
|
||||
|
||||
<AudioControls
|
||||
audioInputs={audioInputs}
|
||||
selectedAudioInput={selectedAudioInput}
|
||||
setSelectedAudioInput={setSelectedAudioInput}
|
||||
isCompleted={isCompleted}
|
||||
connectionActive={connectionActive}
|
||||
permissionError={permissionError}
|
||||
start={start}
|
||||
stop={stop}
|
||||
isStarting={isStarting}
|
||||
/>
|
||||
|
||||
<ConnectionStatus
|
||||
iceGatheringState={iceGatheringState}
|
||||
iceConnectionState={iceConnectionState}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex justify-between">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
WebRTC connection status: {connectionActive ? 'Active' : 'Inactive'}
|
||||
</p>
|
||||
<audio ref={audioRef} autoPlay playsInline className="hidden" />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<ApiKeyErrorDialog
|
||||
open={apiKeyModalOpen}
|
||||
onOpenChange={setApiKeyModalOpen}
|
||||
error={apiKeyError}
|
||||
onNavigateToApiKeys={navigateToApiKeys}
|
||||
/>
|
||||
|
||||
<WorkflowConfigErrorDialog
|
||||
open={workflowConfigModalOpen}
|
||||
onOpenChange={setWorkflowConfigModalOpen}
|
||||
error={workflowConfigError}
|
||||
onNavigateToWorkflow={navigateToWorkflow}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Pipecat;
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
|
||||
interface ApiKeyErrorDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
error: string | null;
|
||||
onNavigateToApiKeys: () => void;
|
||||
}
|
||||
|
||||
export const ApiKeyErrorDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
error,
|
||||
onNavigateToApiKeys
|
||||
}: ApiKeyErrorDialogProps) => {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>API Key Error</DialogTitle>
|
||||
<DialogDescription className="text-red-500 whitespace-pre-line">
|
||||
{error}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button onClick={onNavigateToApiKeys}>
|
||||
Go to API Keys Settings
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
import { Mic, MicOff } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
interface AudioControlsProps {
|
||||
audioInputs: MediaDeviceInfo[];
|
||||
selectedAudioInput: string;
|
||||
setSelectedAudioInput: (deviceId: string) => void;
|
||||
isCompleted: boolean;
|
||||
connectionActive: boolean;
|
||||
permissionError: string | null;
|
||||
start: () => Promise<void>;
|
||||
stop: () => void;
|
||||
isStarting: boolean;
|
||||
}
|
||||
|
||||
export const AudioControls = ({
|
||||
audioInputs,
|
||||
selectedAudioInput,
|
||||
setSelectedAudioInput,
|
||||
isCompleted,
|
||||
connectionActive,
|
||||
permissionError,
|
||||
start,
|
||||
stop,
|
||||
isStarting
|
||||
}: AudioControlsProps) => {
|
||||
// Check if we have valid audio devices (permissions granted)
|
||||
const hasValidDevices = audioInputs.length > 0 && audioInputs.some(device => device.deviceId && device.deviceId.trim() !== '');
|
||||
const validAudioInputs = audioInputs.filter(device => device.deviceId && device.deviceId.trim() !== '');
|
||||
|
||||
const requestAudioPermissions = async () => {
|
||||
try {
|
||||
await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
// This will trigger the parent component to refresh the device list
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Failed to request audio permissions:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Audio Input</h3>
|
||||
|
||||
{!hasValidDevices ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2 text-amber-600 bg-amber-50 p-3 rounded-md border border-amber-200">
|
||||
<MicOff className="h-4 w-4" />
|
||||
<span className="text-sm">Audio permissions are required to start the call</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={requestAudioPermissions}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<Mic className="h-4 w-4 mr-2" />
|
||||
Grant Audio Permissions
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Select value={selectedAudioInput} onValueChange={setSelectedAudioInput}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select audio input" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{validAudioInputs.map((device, index) => (
|
||||
<SelectItem key={device.deviceId} value={device.deviceId}>
|
||||
{device.label || `Audio Device #${index + 1}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isCompleted && (
|
||||
<div className="flex items-center space-x-4">
|
||||
<p className="text-red-500">
|
||||
Workflow run completed. Please refresh the page in a while to see the recording and transcript.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCompleted && hasValidDevices && (
|
||||
<div className="flex items-center space-x-4">
|
||||
{!connectionActive ? (
|
||||
<Button onClick={start} disabled={isStarting}>
|
||||
{isStarting ? 'Starting...' : 'Start'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={stop} variant="destructive">Stop</Button>
|
||||
)}
|
||||
{permissionError && (
|
||||
<p className="text-red-500">{permissionError}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
interface ConnectionStatusProps {
|
||||
iceGatheringState: string;
|
||||
iceConnectionState: string;
|
||||
}
|
||||
|
||||
export const ConnectionStatus = ({
|
||||
iceGatheringState,
|
||||
iceConnectionState
|
||||
}: ConnectionStatusProps) => {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">ICE gathering state</h3>
|
||||
<p className="text-sm text-muted-foreground">{iceGatheringState}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">ICE connection state</h3>
|
||||
<p className="text-sm text-muted-foreground">{iceConnectionState}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
interface ContextDisplayProps {
|
||||
title: string;
|
||||
context: Record<string, string | number | boolean | object> | null;
|
||||
}
|
||||
|
||||
export const ContextDisplay = ({ title, context }: ContextDisplayProps) => {
|
||||
if (!context || Object.keys(context).length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">No {title.toLowerCase()} available</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Object.entries(context).map(([key, value]) => (
|
||||
<div key={key} className="space-y-1">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
{key}
|
||||
</label>
|
||||
<div className="p-3 bg-gray-50 border rounded-md">
|
||||
<p className="text-sm text-gray-900 whitespace-pre-wrap">
|
||||
{typeof value === 'object' && value !== null ? JSON.stringify(value, null, 2) : (value || 'No value')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
import { Trash2Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface ContextVariablesSectionProps {
|
||||
initialContext: Record<string, string>;
|
||||
setInitialContext: (variables: Record<string, string>) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const ContextVariablesSection = ({
|
||||
initialContext,
|
||||
setInitialContext,
|
||||
disabled = false
|
||||
}: ContextVariablesSectionProps) => {
|
||||
const [newKey, setNewKey] = useState("");
|
||||
const [newValue, setNewValue] = useState("");
|
||||
|
||||
const handleAddContextVar = () => {
|
||||
if (newKey && newValue && !initialContext[newKey]) {
|
||||
setInitialContext({ ...initialContext, [newKey]: newValue });
|
||||
setNewKey("");
|
||||
setNewValue("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveContextVar = (key: string) => {
|
||||
const newVars = { ...initialContext };
|
||||
delete newVars[key];
|
||||
setInitialContext(newVars);
|
||||
};
|
||||
|
||||
const handleUpdateContextVar = (key: string, value: string) => {
|
||||
setInitialContext({ ...initialContext, [key]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Template Context Variables</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Existing Variables */}
|
||||
{Object.entries(initialContext).length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Current Variables</Label>
|
||||
{Object.entries(initialContext).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center gap-2 p-3 border rounded-md bg-gray-50">
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs text-gray-600">{key}</Label>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => handleUpdateContextVar(key, e.target.value)}
|
||||
disabled={disabled}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveContextVar(key)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Trash2Icon className="w-4 h-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add New Variable */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">Add New Variable</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder="Variable key"
|
||||
value={newKey}
|
||||
onChange={(e) => setNewKey(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder="Variable value"
|
||||
value={newValue}
|
||||
onChange={(e) => setNewValue(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleAddContextVar}
|
||||
disabled={!newKey || !newValue || disabled || !!initialContext[newKey]}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{newKey && initialContext[newKey] && (
|
||||
<p className="text-sm text-red-500">Variable with this key already exists</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
|
||||
interface WorkflowConfigErrorProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
error: string | null;
|
||||
onNavigateToWorkflow: () => void;
|
||||
}
|
||||
|
||||
export const WorkflowConfigErrorDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
error,
|
||||
onNavigateToWorkflow
|
||||
}: WorkflowConfigErrorProps) => {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Workflow Error</DialogTitle>
|
||||
<DialogDescription className="text-red-500 whitespace-pre-line">
|
||||
{error}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button onClick={onNavigateToWorkflow}>
|
||||
Go to Workflow
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export * from './ApiKeyErrorDialog';
|
||||
export * from './AudioControls';
|
||||
export * from './ConnectionStatus';
|
||||
export * from './ContextDisplay';
|
||||
export * from './ContextVariablesSection';
|
||||
export * from './WorkflowConfigErrorDialog'
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export * from './useDeviceInputs';
|
||||
export * from './useWebRTC';
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
import logger from '@/lib/logger';
|
||||
|
||||
export const useDeviceInputs = () => {
|
||||
const [audioInputs, setAudioInputs] = useState<MediaDeviceInfo[]>([]);
|
||||
const [selectedAudioInput, setSelectedAudioInput] = useState('');
|
||||
const [permissionError, setPermissionError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const getAudioInputs = async () => {
|
||||
try {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
const audioDevices = devices.filter(device => device.kind === 'audioinput');
|
||||
setAudioInputs(audioDevices);
|
||||
|
||||
const defaultAudioInput = audioDevices.find(device => device.deviceId === 'default');
|
||||
if (defaultAudioInput) {
|
||||
setSelectedAudioInput(defaultAudioInput.deviceId);
|
||||
}
|
||||
} catch (error) {
|
||||
setPermissionError('Could not enumerate devices');
|
||||
logger.error(`Error enumerating devices: ${error}`);
|
||||
}
|
||||
};
|
||||
getAudioInputs();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
audioInputs,
|
||||
selectedAudioInput,
|
||||
setSelectedAudioInput,
|
||||
permissionError,
|
||||
setPermissionError
|
||||
};
|
||||
};
|
||||
275
ui/src/app/workflow/[workflowId]/run/[runId]/hooks/useWebRTC.tsx
Normal file
275
ui/src/app/workflow/[workflowId]/run/[runId]/hooks/useWebRTC.tsx
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
import { useRef, useState } from "react";
|
||||
|
||||
import { offerApiV1PipecatRtcOfferPost, validateUserConfigurationsApiV1UserConfigurationsUserValidateGet, validateWorkflowApiV1WorkflowWorkflowIdValidatePost } from "@/client/sdk.gen";
|
||||
import { WorkflowValidationError } from "@/components/flow/types";
|
||||
import logger from '@/lib/logger';
|
||||
import { getRandomId } from "@/lib/utils";
|
||||
|
||||
import { sdpFilterCodec } from "../utils";
|
||||
import { useDeviceInputs } from "./useDeviceInputs";
|
||||
|
||||
interface UseWebRTCProps {
|
||||
workflowId: number;
|
||||
workflowRunId: number;
|
||||
accessToken: string | null;
|
||||
initialContextVariables?: Record<string, string> | null;
|
||||
}
|
||||
|
||||
export const useWebRTC = ({ workflowId, workflowRunId, accessToken, initialContextVariables }: UseWebRTCProps) => {
|
||||
const [iceGatheringState, setIceGatheringState] = useState('');
|
||||
const [iceConnectionState, setIceConnectionState] = useState('');
|
||||
const [connectionActive, setConnectionActive] = useState(false);
|
||||
const [isCompleted, setIsCompleted] = useState(false);
|
||||
const [apiKeyModalOpen, setApiKeyModalOpen] = useState(false);
|
||||
const [apiKeyError, setApiKeyError] = useState<string | null>(null);
|
||||
const [workflowConfigModalOpen, setWorkflowConfigModalOpen] = useState(false);
|
||||
const [workflowConfigError, setWorkflowConfigError] = useState<string | null>(null);
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
const [initialContext, setInitialContext] = useState<Record<string, string>>(
|
||||
initialContextVariables || {}
|
||||
);
|
||||
|
||||
const {
|
||||
audioInputs,
|
||||
selectedAudioInput,
|
||||
setSelectedAudioInput,
|
||||
permissionError,
|
||||
setPermissionError
|
||||
} = useDeviceInputs();
|
||||
|
||||
const useStun = true;
|
||||
const useAudio = true;
|
||||
const audioCodec = 'default';
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const pcRef = useRef<RTCPeerConnection | null>(null);
|
||||
const timeStartRef = useRef<number | null>(null);
|
||||
const pc_id = 'PC-' + getRandomId().toString();
|
||||
|
||||
const createPeerConnection = () => {
|
||||
const config: RTCConfiguration = {
|
||||
iceServers: useStun ? [{ urls: ['stun:stun.l.google.com:19302'] }] : []
|
||||
};
|
||||
|
||||
const pc = new RTCPeerConnection(config);
|
||||
|
||||
pc.addEventListener('icegatheringstatechange', () => {
|
||||
logger.info(`ICE gathering state changed in createPeerConnection, ${pc.iceGatheringState}`);
|
||||
setIceGatheringState(prevState => prevState + ' -> ' + pc.iceGatheringState);
|
||||
});
|
||||
setIceGatheringState(pc.iceGatheringState);
|
||||
|
||||
pc.addEventListener('iceconnectionstatechange', () => {
|
||||
setIceConnectionState(prevState => prevState + ' -> ' + pc.iceConnectionState);
|
||||
});
|
||||
setIceConnectionState(pc.iceConnectionState);
|
||||
|
||||
pc.addEventListener('track', (evt) => {
|
||||
if (evt.track.kind === 'audio' && audioRef.current) {
|
||||
audioRef.current.srcObject = evt.streams[0];
|
||||
}
|
||||
});
|
||||
|
||||
pcRef.current = pc;
|
||||
return pc;
|
||||
};
|
||||
|
||||
const negotiate = async () => {
|
||||
const pc = pcRef.current;
|
||||
if (!pc) return;
|
||||
|
||||
try {
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
resolve();
|
||||
} else {
|
||||
const checkState = () => {
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
logger.debug(`ICE gathering is complete in negotiate, ${pc.iceGatheringState}`);
|
||||
pc.removeEventListener('icegatheringstatechange', checkState);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
pc.addEventListener('icegatheringstatechange', checkState);
|
||||
}
|
||||
});
|
||||
|
||||
const localDescription = pc.localDescription;
|
||||
if (!localDescription) return;
|
||||
|
||||
let sdp = localDescription.sdp;
|
||||
|
||||
if (audioCodec !== 'default') {
|
||||
sdp = sdpFilterCodec('audio', audioCodec, sdp);
|
||||
}
|
||||
|
||||
if (!accessToken) return;
|
||||
|
||||
const response = await offerApiV1PipecatRtcOfferPost({
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
body: {
|
||||
sdp: sdp,
|
||||
type: 'offer',
|
||||
pc_id: pc_id,
|
||||
restart_pc: false,
|
||||
workflow_id: workflowId,
|
||||
workflow_run_id: workflowRunId,
|
||||
call_context_vars: initialContext
|
||||
}
|
||||
});
|
||||
|
||||
if (response && response.data) {
|
||||
const answerSdpText = typeof response.data === 'object' && 'sdp' in response.data
|
||||
? response.data.sdp as string
|
||||
: '';
|
||||
|
||||
await pc.setRemoteDescription({
|
||||
type: 'answer',
|
||||
sdp: answerSdpText
|
||||
});
|
||||
setConnectionActive(true);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(`Negotiation failed: ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
const start = async () => {
|
||||
if (isStarting || !accessToken) return;
|
||||
setIsStarting(true);
|
||||
try {
|
||||
const response = await validateUserConfigurationsApiV1UserConfigurationsUserValidateGet({
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
query: {
|
||||
validity_ttl_seconds: 86400
|
||||
},
|
||||
});
|
||||
if (response.error) {
|
||||
setApiKeyModalOpen(true);
|
||||
let msg = 'API Key Error';
|
||||
const detail = (response.error as unknown as { detail?: { errors: { model: string; message: string }[] } }).detail;
|
||||
if (Array.isArray(detail)) {
|
||||
msg = detail
|
||||
.map((e: { model: string; message: string }) => `${e.model}: ${e.message}`)
|
||||
.join('\n');
|
||||
}
|
||||
setApiKeyError(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
// Then check workflow validation
|
||||
const workflowResponse = await validateWorkflowApiV1WorkflowWorkflowIdValidatePost({
|
||||
path: {
|
||||
workflow_id: workflowId,
|
||||
},
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (workflowResponse.error) {
|
||||
setWorkflowConfigModalOpen(true);
|
||||
let msg = 'Workflow validation failed';
|
||||
const errorDetail = workflowResponse.error as { detail?: { errors: WorkflowValidationError[] } };
|
||||
if (errorDetail?.detail?.errors) {
|
||||
msg = errorDetail.detail.errors
|
||||
.map(err => `${err.kind}: ${err.message}`)
|
||||
.join('\n');
|
||||
}
|
||||
setWorkflowConfigError(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
timeStartRef.current = null;
|
||||
const pc = createPeerConnection();
|
||||
|
||||
const constraints: MediaStreamConstraints = {
|
||||
audio: false,
|
||||
};
|
||||
|
||||
if (useAudio) {
|
||||
const audioConstraints: MediaTrackConstraints = {};
|
||||
if (selectedAudioInput) {
|
||||
audioConstraints.deviceId = { exact: selectedAudioInput };
|
||||
}
|
||||
constraints.audio = Object.keys(audioConstraints).length ? audioConstraints : true;
|
||||
}
|
||||
|
||||
if (constraints.audio) {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
stream.getTracks().forEach((track) => {
|
||||
pc.addTrack(track, stream);
|
||||
});
|
||||
await negotiate();
|
||||
} catch (err) {
|
||||
logger.error(`Could not acquire media: ${err}`);
|
||||
setPermissionError('Could not acquire media');
|
||||
}
|
||||
} else {
|
||||
await negotiate();
|
||||
}
|
||||
} finally {
|
||||
setIsStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
setConnectionActive(false);
|
||||
setIsCompleted(true);
|
||||
|
||||
const pc = pcRef.current;
|
||||
if (!pc) return;
|
||||
|
||||
if (pc.getTransceivers) {
|
||||
pc.getTransceivers().forEach((transceiver) => {
|
||||
if (transceiver.stop) {
|
||||
transceiver.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pc.getSenders().forEach((sender) => {
|
||||
if (sender.track) {
|
||||
sender.track.stop();
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (pcRef.current) {
|
||||
pcRef.current.close();
|
||||
pcRef.current = null;
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
return {
|
||||
audioRef,
|
||||
audioInputs,
|
||||
selectedAudioInput,
|
||||
setSelectedAudioInput,
|
||||
connectionActive,
|
||||
permissionError,
|
||||
isCompleted,
|
||||
apiKeyModalOpen,
|
||||
setApiKeyModalOpen,
|
||||
apiKeyError,
|
||||
workflowConfigError,
|
||||
workflowConfigModalOpen,
|
||||
setWorkflowConfigModalOpen,
|
||||
iceGatheringState,
|
||||
iceConnectionState,
|
||||
start,
|
||||
stop,
|
||||
isStarting,
|
||||
initialContext,
|
||||
setInitialContext
|
||||
};
|
||||
};
|
||||
217
ui/src/app/workflow/[workflowId]/run/[runId]/page.tsx
Normal file
217
ui/src/app/workflow/[workflowId]/run/[runId]/page.tsx
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
'use client';
|
||||
|
||||
import { ArrowLeft, FileText, Video } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import Pipecat from '@/app/workflow/[workflowId]/run/[runId]/Pipecat';
|
||||
import WorkflowLayout from '@/app/workflow/WorkflowLayout';
|
||||
import { getWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGet } from '@/client/sdk.gen';
|
||||
import { MediaPreviewButtons, MediaPreviewDialog } from '@/components/MediaPreviewDialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import { downloadFile } from '@/lib/files';
|
||||
|
||||
import { ContextDisplay } from './components';
|
||||
|
||||
interface WorkflowRunResponse {
|
||||
is_completed: boolean;
|
||||
transcript_url: string | null;
|
||||
recording_url: string | null;
|
||||
initial_context: Record<string, string | number | boolean | object> | null;
|
||||
gathered_context: Record<string, string | number | boolean | object> | null;
|
||||
}
|
||||
|
||||
|
||||
export default function WorkflowRunPage() {
|
||||
const params = useParams();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const auth = useAuth();
|
||||
const [workflowRun, setWorkflowRun] = useState<WorkflowRunResponse | null>(null);
|
||||
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||
|
||||
// Redirect if not authenticated
|
||||
useEffect(() => {
|
||||
if (!auth.loading && !auth.isAuthenticated) {
|
||||
auth.redirectToLogin();
|
||||
}
|
||||
}, [auth]);
|
||||
|
||||
// Get access token
|
||||
useEffect(() => {
|
||||
if (auth.isAuthenticated && !auth.loading) {
|
||||
auth.getAccessToken().then(setAccessToken);
|
||||
}
|
||||
}, [auth]);
|
||||
|
||||
const { openAudioModal, openTranscriptModal, dialog } = MediaPreviewDialog({ accessToken });
|
||||
|
||||
useEffect(() => {
|
||||
const fetchWorkflowRun = async () => {
|
||||
if (!auth.isAuthenticated || auth.loading) return;
|
||||
|
||||
setIsLoading(true);
|
||||
const token = await auth.getAccessToken();
|
||||
const workflowId = params.workflowId;
|
||||
const runId = params.runId;
|
||||
const response = await getWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGet({
|
||||
path: {
|
||||
workflow_id: Number(workflowId),
|
||||
run_id: Number(runId),
|
||||
},
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
setIsLoading(false);
|
||||
setWorkflowRun({
|
||||
is_completed: response.data?.is_completed ?? false,
|
||||
transcript_url: response.data?.transcript_url ?? null,
|
||||
recording_url: response.data?.recording_url ?? null,
|
||||
initial_context: response.data?.initial_context as Record<string, string> | null ?? null,
|
||||
gathered_context: response.data?.gathered_context as Record<string, string> | null ?? null,
|
||||
});
|
||||
};
|
||||
fetchWorkflowRun();
|
||||
}, [params.workflowId, params.runId, auth]);
|
||||
|
||||
const backButton = (
|
||||
<div className="flex gap-2">
|
||||
<Link href={`/workflow/${params.workflowId}`}>
|
||||
<Button variant="outline" size="sm" className="flex items-center gap-1">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Workflow
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/workflow/${params.workflowId}/runs`}>
|
||||
<Button variant="outline" size="sm" className="flex items-center gap-1">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Workflow Runs
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
let returnValue = null;
|
||||
|
||||
if (isLoading) {
|
||||
returnValue = (
|
||||
<div className="min-h-screen flex mt-40 justify-center">
|
||||
<div className="w-full max-w-4xl p-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-48" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</CardContent>
|
||||
<CardFooter className="flex gap-4">
|
||||
<Skeleton className="h-10 w-32" />
|
||||
<Skeleton className="h-10 w-32" />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
else if (workflowRun?.is_completed) {
|
||||
returnValue = (
|
||||
<div className="min-h-screen flex mt-40 justify-center p-6">
|
||||
<div className="w-full max-w-4xl space-y-6">
|
||||
<Card className="border-gray-100">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-2xl">Workflow Run Completed</CardTitle>
|
||||
<div className="h-8 w-8 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<svg className="h-5 w-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-600 mb-8">Your workflow run has been completed successfully. You can preview or download the transcript and recording.</p>
|
||||
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">Preview:</span>
|
||||
<MediaPreviewButtons
|
||||
recordingUrl={workflowRun?.recording_url}
|
||||
transcriptUrl={workflowRun?.transcript_url}
|
||||
runId={Number(params.runId)}
|
||||
onOpenAudio={openAudioModal}
|
||||
onOpenTranscript={openTranscriptModal}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 border-l pl-4">
|
||||
<span className="text-sm text-gray-600">Download:</span>
|
||||
<Button
|
||||
onClick={() => downloadFile(workflowRun?.transcript_url, accessToken!)}
|
||||
disabled={!workflowRun?.transcript_url || !accessToken}
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
Transcript
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => downloadFile(workflowRun?.recording_url, accessToken!)}
|
||||
disabled={!workflowRun?.recording_url || !accessToken}
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<Video className="h-4 w-4" />
|
||||
Recording
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<ContextDisplay
|
||||
title="Initial Context"
|
||||
context={workflowRun?.initial_context}
|
||||
/>
|
||||
<ContextDisplay
|
||||
title="Gathered Context"
|
||||
context={workflowRun?.gathered_context}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
else {
|
||||
returnValue =
|
||||
<div className="min-h-screen mt-40">
|
||||
<Pipecat
|
||||
workflowId={Number(params.workflowId)}
|
||||
workflowRunId={Number(params.runId)}
|
||||
accessToken={accessToken}
|
||||
initialContextVariables={
|
||||
workflowRun?.initial_context
|
||||
? Object.fromEntries(
|
||||
Object.entries(workflowRun.initial_context).map(([key, value]) => [
|
||||
key,
|
||||
typeof value === 'object' && value !== null
|
||||
? JSON.stringify(value)
|
||||
: String(value)
|
||||
])
|
||||
)
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkflowLayout backButton={backButton}>
|
||||
{returnValue}
|
||||
{dialog}
|
||||
</WorkflowLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './webrtcUtils';
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
/**
|
||||
* Escapes special characters in a string for use in a regular expression.
|
||||
*/
|
||||
export const escapeRegExp = (string: string): string => {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
};
|
||||
|
||||
/**
|
||||
* Filters codecs in an SDP string.
|
||||
*/
|
||||
export const sdpFilterCodec = (kind: string, codec: string, realSdp: string): string => {
|
||||
const allowed: number[] = [];
|
||||
const rtxRegex = new RegExp('a=fmtp:(\\d+) apt=(\\d+)\r$');
|
||||
const codecRegex = new RegExp('a=rtpmap:([0-9]+) ' + escapeRegExp(codec));
|
||||
const videoRegex = new RegExp('(m=' + kind + ' .*?)( ([0-9]+))*\\s*$');
|
||||
|
||||
const lines = realSdp.split('\n');
|
||||
|
||||
let isKind = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].startsWith('m=' + kind + ' ')) {
|
||||
isKind = true;
|
||||
} else if (lines[i].startsWith('m=')) {
|
||||
isKind = false;
|
||||
}
|
||||
|
||||
if (isKind) {
|
||||
let match = lines[i].match(codecRegex);
|
||||
if (match) {
|
||||
allowed.push(parseInt(match[1]));
|
||||
}
|
||||
|
||||
match = lines[i].match(rtxRegex);
|
||||
if (match && allowed.includes(parseInt(match[2]))) {
|
||||
allowed.push(parseInt(match[1]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const skipRegex = 'a=(fmtp|rtcp-fb|rtpmap):([0-9]+)';
|
||||
let sdp = '';
|
||||
|
||||
isKind = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].startsWith('m=' + kind + ' ')) {
|
||||
isKind = true;
|
||||
} else if (lines[i].startsWith('m=')) {
|
||||
isKind = false;
|
||||
}
|
||||
|
||||
if (isKind) {
|
||||
const skipMatch = lines[i].match(skipRegex);
|
||||
if (skipMatch && !allowed.includes(parseInt(skipMatch[2]))) {
|
||||
continue;
|
||||
} else if (lines[i].match(videoRegex)) {
|
||||
sdp += lines[i].replace(videoRegex, '$1 ' + allowed.join(' ')) + '\n';
|
||||
} else {
|
||||
sdp += lines[i] + '\n';
|
||||
}
|
||||
} else {
|
||||
sdp += lines[i] + '\n';
|
||||
}
|
||||
}
|
||||
|
||||
return sdp;
|
||||
};
|
||||
359
ui/src/app/workflow/[workflowId]/runs/page.tsx
Normal file
359
ui/src/app/workflow/[workflowId]/runs/page.tsx
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight, Download, ExternalLink } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { getWorkflowApiV1WorkflowFetchWorkflowIdGet,getWorkflowRunsApiV1WorkflowWorkflowIdRunsGet } from "@/client/sdk.gen";
|
||||
import { WorkflowRunResponseSchema } from "@/client/types.gen";
|
||||
import { FilterBuilder } from "@/components/filters/FilterBuilder";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { DISPOSITION_CODES } from "@/constants/dispositionCodes";
|
||||
import { useUserConfig } from '@/context/UserConfigContext';
|
||||
import { getDispositionBadgeVariant } from '@/lib/dispositionBadgeVariant';
|
||||
import { downloadFile } from "@/lib/files";
|
||||
import { decodeFiltersFromURL, encodeFiltersToURL } from "@/lib/filters";
|
||||
import { ActiveFilter, availableAttributes, FilterAttribute } from "@/types/filters";
|
||||
|
||||
import WorkflowLayout from '../../WorkflowLayout';
|
||||
|
||||
export default function WorkflowRunsPage() {
|
||||
const { workflowId } = useParams();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [workflowRuns, setWorkflowRuns] = useState<WorkflowRunResponseSchema[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(() => {
|
||||
const pageParam = searchParams.get('page');
|
||||
return pageParam ? parseInt(pageParam, 10) : 1;
|
||||
});
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [isExecutingFilters, setIsExecutingFilters] = useState(false);
|
||||
const [configuredAttributes, setConfiguredAttributes] = useState<FilterAttribute[]>(availableAttributes);
|
||||
|
||||
const { accessToken } = useUserConfig();
|
||||
|
||||
// Initialize filters from URL
|
||||
const [activeFilters, setActiveFilters] = useState<ActiveFilter[]>(() => {
|
||||
return decodeFiltersFromURL(searchParams, availableAttributes);
|
||||
});
|
||||
|
||||
const formatDate = (dateString: string) => new Date(dateString).toLocaleString();
|
||||
|
||||
|
||||
// Load disposition codes from workflow configuration
|
||||
useEffect(() => {
|
||||
const loadDispositionCodes = async () => {
|
||||
if (!accessToken) return;
|
||||
try {
|
||||
const response = await getWorkflowApiV1WorkflowFetchWorkflowIdGet({
|
||||
path: { workflow_id: Number(workflowId) },
|
||||
headers: { 'Authorization': `Bearer ${accessToken}` }
|
||||
});
|
||||
|
||||
const workflow = response.data;
|
||||
if (workflow?.call_disposition_codes) {
|
||||
// Update the disposition code attribute with actual options
|
||||
const updatedAttributes = configuredAttributes.map(attr => {
|
||||
if (attr.id === 'dispositionCode') {
|
||||
return {
|
||||
...attr,
|
||||
config: {
|
||||
...attr.config,
|
||||
options: Object.keys(workflow.call_disposition_codes || {}).length > 0
|
||||
? Object.keys(workflow.call_disposition_codes || {})
|
||||
: [...DISPOSITION_CODES]
|
||||
}
|
||||
};
|
||||
}
|
||||
return attr;
|
||||
});
|
||||
setConfiguredAttributes(updatedAttributes);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load disposition codes:", err);
|
||||
}
|
||||
};
|
||||
|
||||
loadDispositionCodes();
|
||||
}, [workflowId, accessToken, configuredAttributes]);
|
||||
|
||||
const fetchWorkflowRuns = useCallback(async (page: number, filters?: ActiveFilter[]) => {
|
||||
if (!accessToken) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
// Prepare filter data for API
|
||||
let filterParam = undefined;
|
||||
if (filters && filters.length > 0) {
|
||||
const filterData = filters.map(filter => ({
|
||||
attribute: filter.attribute.id,
|
||||
type: filter.attribute.type,
|
||||
value: filter.value
|
||||
}));
|
||||
filterParam = JSON.stringify(filterData);
|
||||
}
|
||||
|
||||
const response = await getWorkflowRunsApiV1WorkflowWorkflowIdRunsGet({
|
||||
path: { workflow_id: Number(workflowId) },
|
||||
query: {
|
||||
page: page,
|
||||
limit: 50,
|
||||
...(filterParam && { filters: filterParam })
|
||||
},
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
}
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw new Error("Failed to fetch workflow runs");
|
||||
}
|
||||
|
||||
if (response.data) {
|
||||
setWorkflowRuns(response.data.runs || []);
|
||||
setTotalPages(response.data.total_pages || 1);
|
||||
setTotalCount(response.data.total_count || 0);
|
||||
setCurrentPage(response.data.page || 1);
|
||||
}
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error("Error fetching workflow runs:", err);
|
||||
setError("Failed to load workflow runs");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [workflowId, accessToken]);
|
||||
|
||||
const updatePageInUrl = useCallback((page: number, filters?: ActiveFilter[]) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', page.toString());
|
||||
|
||||
// Add filters to URL if present
|
||||
if (filters && filters.length > 0) {
|
||||
const filterString = encodeFiltersToURL(filters);
|
||||
if (filterString) {
|
||||
const filterParams = new URLSearchParams(filterString);
|
||||
filterParams.forEach((value, key) => params.set(key, value));
|
||||
}
|
||||
}
|
||||
|
||||
router.push(`/workflow/${workflowId}/runs?${params.toString()}`);
|
||||
}, [router, workflowId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchWorkflowRuns(currentPage, activeFilters);
|
||||
}, [currentPage, activeFilters, fetchWorkflowRuns]);
|
||||
|
||||
const handleApplyFilters = useCallback(async () => {
|
||||
setIsExecutingFilters(true);
|
||||
setCurrentPage(1); // Reset to first page when applying filters
|
||||
updatePageInUrl(1, activeFilters);
|
||||
await fetchWorkflowRuns(1, activeFilters);
|
||||
setIsExecutingFilters(false);
|
||||
}, [activeFilters, fetchWorkflowRuns, updatePageInUrl]);
|
||||
|
||||
const handleFiltersChange = useCallback((filters: ActiveFilter[]) => {
|
||||
setActiveFilters(filters);
|
||||
}, []);
|
||||
|
||||
const handleClearFilters = useCallback(async () => {
|
||||
setIsExecutingFilters(true);
|
||||
setCurrentPage(1);
|
||||
updatePageInUrl(1, []); // Clear filters from URL
|
||||
await fetchWorkflowRuns(1, []); // Fetch all workflows without filters
|
||||
setIsExecutingFilters(false);
|
||||
}, [fetchWorkflowRuns, updatePageInUrl]);
|
||||
|
||||
const backButton = (
|
||||
<Link href={`/workflow/${workflowId}`}>
|
||||
<Button variant="outline" size="sm" className="flex items-center gap-1">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Workflow
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
|
||||
return (
|
||||
<WorkflowLayout backButton={backButton}>
|
||||
<div className="container mx-auto py-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold mb-4">Workflow Run History</h1>
|
||||
<FilterBuilder
|
||||
availableAttributes={configuredAttributes}
|
||||
activeFilters={activeFilters}
|
||||
onFiltersChange={handleFiltersChange}
|
||||
onApplyFilters={handleApplyFilters}
|
||||
onClearFilters={handleClearFilters}
|
||||
isExecuting={isExecutingFilters}
|
||||
/>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex justify-center">
|
||||
<div className="animate-pulse">Loading workflow runs...</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
) : workflowRuns.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-500">No workflow runs found</p>
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Workflow Runs</CardTitle>
|
||||
<CardDescription>
|
||||
Showing {workflowRuns.length} of {totalCount} total runs
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="bg-white border rounded-lg overflow-hidden shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead className="font-semibold">ID</TableHead>
|
||||
<TableHead className="font-semibold">Status</TableHead>
|
||||
<TableHead className="font-semibold">Created At</TableHead>
|
||||
<TableHead className="font-semibold">Duration</TableHead>
|
||||
<TableHead className="font-semibold">Disposition</TableHead>
|
||||
<TableHead className="font-semibold">Dograh Token</TableHead>
|
||||
<TableHead className="font-semibold">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{workflowRuns.map((run) => (
|
||||
<TableRow
|
||||
key={run.id}
|
||||
className="cursor-pointer"
|
||||
onClick={() => router.push(`/workflow/${workflowId}/run/${run.id}`)}
|
||||
>
|
||||
<TableCell className="font-mono text-sm">#{run.id}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={run.is_completed ? "default" : "secondary"}>
|
||||
{run.is_completed ? "Completed" : "In Progress"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{formatDate(run.created_at)}</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{typeof run.cost_info?.call_duration_seconds === 'number'
|
||||
? `${run.cost_info.call_duration_seconds.toFixed(1)}s`
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{run.gathered_context?.mapped_call_disposition ? (
|
||||
<Badge variant={getDispositionBadgeVariant(run.gathered_context.mapped_call_disposition as string)}>
|
||||
{run.gathered_context.mapped_call_disposition as string}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{typeof run.cost_info?.dograh_token_usage === 'number'
|
||||
? `${run.cost_info.dograh_token_usage.toFixed(2)}`
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex space-x-2">
|
||||
{run.transcript_url && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (accessToken) downloadFile(run.transcript_url, accessToken);
|
||||
}}
|
||||
>
|
||||
<Download className="h-3 w-3 mr-1" />
|
||||
Transcript
|
||||
</Button>
|
||||
)}
|
||||
{run.recording_url && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (accessToken) downloadFile(run.recording_url, accessToken);
|
||||
}}
|
||||
>
|
||||
<Download className="h-3 w-3 mr-1" />
|
||||
Recording
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/workflow/${workflowId}/run/${run.id}`);
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3 mr-1" />
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-6">
|
||||
<p className="text-sm text-gray-600">
|
||||
Page {currentPage} of {totalPages}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newPage = currentPage - 1;
|
||||
setCurrentPage(newPage);
|
||||
updatePageInUrl(newPage, activeFilters);
|
||||
}}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newPage = currentPage + 1;
|
||||
setCurrentPage(newPage);
|
||||
updatePageInUrl(newPage, activeFilters);
|
||||
}}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</WorkflowLayout>
|
||||
);
|
||||
}
|
||||
215
ui/src/app/workflow/page.tsx
Normal file
215
ui/src/app/workflow/page.tsx
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import { Settings } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import { getWorkflowsApiV1WorkflowFetchGet, getWorkflowTemplatesApiV1WorkflowTemplatesGet } from '@/client/sdk.gen';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CreateWorkflowButton } from "@/components/workflow/CreateWorkflowButton";
|
||||
import { DuplicateWorkflowTemplate } from "@/components/workflow/TemplateCard";
|
||||
import { UploadWorkflowButton } from '@/components/workflow/UploadWorkflowButton';
|
||||
import { WorkflowTable } from "@/components/workflow/WorkflowTable";
|
||||
import { getServerAccessToken, getServerAuthProvider } from '@/lib/auth/server';
|
||||
import logger from '@/lib/logger';
|
||||
|
||||
import WorkflowLayout from "./WorkflowLayout";
|
||||
|
||||
// Server component for workflow templates
|
||||
async function WorkflowTemplatesList() {
|
||||
try {
|
||||
const response = await getWorkflowTemplatesApiV1WorkflowTemplatesGet();
|
||||
// Log request URL if available
|
||||
if (response.request?.url) {
|
||||
logger.info(`Template Request URL: ${response.request.url}`);
|
||||
}
|
||||
const templates = response.data || [];
|
||||
|
||||
// Get access token on server side to pass to client component
|
||||
const accessToken = await getServerAccessToken();
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{templates.map((template) => (
|
||||
<DuplicateWorkflowTemplate
|
||||
key={template.id}
|
||||
id={template.id}
|
||||
title={template.template_name}
|
||||
description={template.template_description}
|
||||
serverAccessToken={accessToken}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(`Error fetching workflow templates: ${err}`);
|
||||
return (
|
||||
<div className="text-red-500">
|
||||
Failed to load Workflow Templates. Please Try Again Later.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Server component for workflow list
|
||||
async function WorkflowList() {
|
||||
const authProvider = getServerAuthProvider();
|
||||
const accessToken = await getServerAccessToken();
|
||||
|
||||
logger.debug(`In WorkflowList, authProvider: ${authProvider}, accessToken: ${accessToken}`);
|
||||
|
||||
if (!accessToken) {
|
||||
// If no token, user needs to sign in
|
||||
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 {
|
||||
// Fetch both active and archived workflows in a single request
|
||||
const response = await getWorkflowsApiV1WorkflowFetchGet({
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
query: {
|
||||
status: 'active,archived'
|
||||
}
|
||||
});
|
||||
|
||||
const allWorkflowData = response.data ? (Array.isArray(response.data) ? response.data : [response.data]) : [];
|
||||
|
||||
// Separate active and archived workflows
|
||||
const activeWorkflows = allWorkflowData
|
||||
.filter(w => w.status === 'active')
|
||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
||||
|
||||
const archivedWorkflows = allWorkflowData
|
||||
.filter(w => w.status === 'archived')
|
||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Active Workflows Section */}
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-4">Active Workflows</h2>
|
||||
{activeWorkflows.length > 0 ? (
|
||||
<WorkflowTable workflows={activeWorkflows} showArchived={false} />
|
||||
) : (
|
||||
<div className="text-gray-500 bg-gray-50 rounded-lg p-8 text-center">
|
||||
No active workflows found. Create your first workflow to get started.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Archived Workflows Section */}
|
||||
{archivedWorkflows.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-600">Archived Workflows</h2>
|
||||
<WorkflowTable workflows={archivedWorkflows} showArchived={true} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(`Error fetching workflows: ${err}`);
|
||||
return (
|
||||
<div className="text-red-500">
|
||||
Failed to load Workflows. Please Try Again Later.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function PageContent() {
|
||||
|
||||
const workflowList = await WorkflowList();
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Get Started Section */}
|
||||
<div className="mb-12">
|
||||
<div className="flex justify-between items-center px-4">
|
||||
<h2 className="text-2xl font-bold mb-6">Get Started</h2>
|
||||
<div className="flex gap-2">
|
||||
<Link href="/service-configurations">
|
||||
<Button className="flex items-center gap-2 mb-6">
|
||||
<Settings size={16} />
|
||||
Configure Services
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/integrations">
|
||||
<Button className="flex items-center gap-2 mb-6">
|
||||
<Settings size={16} />
|
||||
Integrations
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Suspense fallback={
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<div key={i} className="bg-gray-200 rounded-lg h-40"></div>
|
||||
))}
|
||||
</div>
|
||||
}>
|
||||
<WorkflowTemplatesList />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{/* Your Workflows Section */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold">Your Workflows</h1>
|
||||
<div className="flex gap-2">
|
||||
<UploadWorkflowButton />
|
||||
<CreateWorkflowButton />
|
||||
</div>
|
||||
</div>
|
||||
{workflowList}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkflowsLoading() {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Get Started Section Loading */}
|
||||
<div className="mb-12">
|
||||
<div className="h-8 w-48 bg-gray-200 rounded mb-6"></div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<div key={i} className="bg-gray-200 rounded-lg h-40"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Your Workflows Section Loading */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="h-8 w-48 bg-gray-200 rounded"></div>
|
||||
<div className="h-10 w-32 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
<div className="bg-gray-200 rounded-lg h-96"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WorkflowPage() {
|
||||
return (
|
||||
<WorkflowLayout showFeaturesNav={true}>
|
||||
<Suspense fallback={<WorkflowsLoading />}>
|
||||
<PageContent />
|
||||
</Suspense>
|
||||
</WorkflowLayout>
|
||||
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue