Initial Commit 🚀 🚀
97
ui/.dockerignore
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Next.js build output
|
||||
.next/
|
||||
out/
|
||||
build/
|
||||
dist/
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
.nyc_output
|
||||
|
||||
# Debugging
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Version control
|
||||
.git/
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# IDE and editor files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
docs/
|
||||
README*
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Misc
|
||||
.eslintcache
|
||||
.npm
|
||||
.yarn
|
||||
|
||||
# CI/CD
|
||||
.github/
|
||||
.gitlab-ci.yml
|
||||
.travis.yml
|
||||
|
||||
# Development tools
|
||||
.pre-commit-config.yaml
|
||||
eslint.config.mjs
|
||||
prettier.config.js
|
||||
jest.config.js
|
||||
openapi-ts.config.ts
|
||||
|
||||
# Docker files
|
||||
Dockerfile
|
||||
docker-compose*.yml
|
||||
docker-compose*.yaml
|
||||
.dockerignore
|
||||
|
||||
# Temporary files
|
||||
*.log
|
||||
*.tmp
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
# Storybook
|
||||
.storybook/
|
||||
storybook-static/
|
||||
|
||||
# Vercel
|
||||
.vercel
|
||||
|
||||
# SWC
|
||||
.swc/
|
||||
|
||||
# Turbo
|
||||
.turbo/
|
||||
|
||||
# Claude/Cursor specific
|
||||
.claude/
|
||||
.cursor/
|
||||
|
||||
# Package manager files (only need package*.json)
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
bun.lockb
|
||||
43
ui/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# Sentry Config File
|
||||
.env.sentry-build-plugin
|
||||
|
||||
.env.local
|
||||
8
ui/.pre-commit-config.yaml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
repos:
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: format-and-lint
|
||||
name: "Run Ruff + ESLint autofix"
|
||||
entry: ./scripts/pre_commit.sh
|
||||
language: script # runs in your shell, cross-platform
|
||||
pass_filenames: false # we run the script once, not per-file
|
||||
33
ui/Dockerfile
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NEXT_PUBLIC_NODE_ENV=local
|
||||
ENV NEXT_PUBLIC_AUTH_PROVIDER=local
|
||||
ENV NEXT_PUBLIC_BACKEND_URL="http://localhost:8000"
|
||||
ENV BACKEND_URL="http://api:8000"
|
||||
|
||||
|
||||
# Copy package files and other config files needed for build
|
||||
COPY package*.json ./
|
||||
COPY tsconfig.json ./
|
||||
COPY next.config.ts ./
|
||||
COPY components.json ./
|
||||
COPY sentry.edge.config.ts ./
|
||||
COPY sentry.server.config.ts ./
|
||||
COPY postcss.config.mjs ./
|
||||
|
||||
# Install dependencies (including dev deps for building)
|
||||
RUN npm ci
|
||||
|
||||
# Copy all source file
|
||||
COPY public ./public
|
||||
COPY src ./src
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# Expose the port Next.js runs on
|
||||
EXPOSE 3000
|
||||
|
||||
# Start the production server
|
||||
CMD ["npm", "run", "start"]
|
||||
23
ui/README.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
### Login Flow
|
||||
|
||||
1. The redirection happens server side using `ui/src/stack.tsx` after the user has logged in.
|
||||
|
||||
### Sentry and PostHog
|
||||
|
||||
1. Initialized in `ui/src/instrumentation-client.ts`
|
||||
21
ui/components.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
41
ui/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
import simpleImportSort from "eslint-plugin-simple-import-sort";
|
||||
import unusedImports from "eslint-plugin-unused-imports";
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
{
|
||||
plugins: {
|
||||
"simple-import-sort": simpleImportSort,
|
||||
"unused-imports": unusedImports,
|
||||
},
|
||||
rules: {
|
||||
/* formatting / layout */
|
||||
"no-multiple-empty-lines": ["error", { max: 2, maxBOF: 1, maxEOF: 1 }],
|
||||
"eol-last": ["error", "always"],
|
||||
"no-trailing-spaces": "error",
|
||||
|
||||
/* imports */
|
||||
"simple-import-sort/imports": "error",
|
||||
"simple-import-sort/exports": "error",
|
||||
"unused-imports/no-unused-imports": "error",
|
||||
"unused-imports/no-unused-vars": ["warn", { vars: "all", args: "after-used" }],
|
||||
|
||||
},
|
||||
languageOptions: {
|
||||
sourceType: "module",
|
||||
ecmaVersion: "latest"
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
64
ui/next.config.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { withSentryConfig } from "@sentry/nextjs";
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
experimental: {
|
||||
serverSourceMaps: true,
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
// API proxy for backend calls
|
||||
{
|
||||
source: "/api/:path*",
|
||||
destination: `${process.env.BACKEND_URL || 'http://localhost:8000'}/api/:path*`,
|
||||
},
|
||||
{
|
||||
source: "/ingest/static/:path*",
|
||||
destination: "https://us-assets.i.posthog.com/static/:path*",
|
||||
},
|
||||
{
|
||||
source: "/ingest/:path*",
|
||||
destination: "https://us.i.posthog.com/:path*",
|
||||
},
|
||||
{
|
||||
source: "/ingest/decide",
|
||||
destination: "https://us.i.posthog.com/decide",
|
||||
},
|
||||
];
|
||||
},
|
||||
// This is required to support PostHog trailing slash API requests
|
||||
skipTrailingSlashRedirect: true,
|
||||
};
|
||||
|
||||
export default withSentryConfig(nextConfig, {
|
||||
// For all available options, see:
|
||||
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
|
||||
|
||||
org: "dograh",
|
||||
project: "javascript-nextjs",
|
||||
|
||||
// Only print logs for uploading source maps in CI
|
||||
silent: !process.env.CI,
|
||||
|
||||
// For all available options, see:
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
|
||||
|
||||
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
||||
widenClientFileUpload: true,
|
||||
|
||||
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
|
||||
// This can increase your server load as well as your hosting bill.
|
||||
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
|
||||
// side errors will fail.
|
||||
tunnelRoute: "/monitoring",
|
||||
|
||||
// Automatically tree-shake Sentry logger statements to reduce bundle size
|
||||
disableLogger: true,
|
||||
|
||||
// Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.)
|
||||
// See the following for more information:
|
||||
// https://docs.sentry.io/product/crons/
|
||||
// https://vercel.com/docs/cron-jobs
|
||||
automaticVercelMonitors: true,
|
||||
});
|
||||
10
ui/openapi-ts.config.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { defineConfig } from '@hey-api/openapi-ts';
|
||||
|
||||
export default defineConfig({
|
||||
input: 'http://127.0.0.1:8000/api/v1/openapi.json',
|
||||
output: 'src/client',
|
||||
plugins: [{
|
||||
name: '@hey-api/client-fetch',
|
||||
runtimeConfigPath: './src/lib/apiClient.ts',
|
||||
}],
|
||||
});
|
||||
17926
ui/package-lock.json
generated
Normal file
73
ui/package.json
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
{
|
||||
"name": "ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "NODE_OPTIONS='--enable-source-maps' next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"fix-lint": "npx eslint --fix . --ignore-pattern '.next/*' --ignore-pattern 'node_modules/*'",
|
||||
"generate-client": "openapi-ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "^1.1.4",
|
||||
"@hey-api/client-fetch": "^0.10.0",
|
||||
"@livekit/components-react": "^2.9.0",
|
||||
"@nangohq/frontend": "^0.60.3",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-dialog": "^1.1.13",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.7",
|
||||
"@radix-ui/react-label": "^2.1.3",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.7",
|
||||
"@radix-ui/react-select": "^2.2.2",
|
||||
"@radix-ui/react-separator": "^1.1.3",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.1.4",
|
||||
"@radix-ui/react-tooltip": "^1.2.0",
|
||||
"@sentry/nextjs": "^9.28.1",
|
||||
"@stackframe/stack": "^2.8.28",
|
||||
"@xyflow/react": "^12.5.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"livekit-client": "^2.9.9",
|
||||
"lucide-react": "^0.487.0",
|
||||
"next": "^15.3.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"pino": "^9.9.2",
|
||||
"pino-pretty": "^13.1.1",
|
||||
"posthog-js": "^1.255.1",
|
||||
"posthog-node": "^5.1.1",
|
||||
"react": "^19.1.0",
|
||||
"react-day-picker": "^9.8.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "^7.56.4",
|
||||
"react-international-phone": "^4.5.0",
|
||||
"react-timezone-select": "^3.2.8",
|
||||
"recharts": "^3.1.2",
|
||||
"shadcn-ui": "^0.9.5",
|
||||
"sonner": "^2.0.5",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tw-animate-css": "^1.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@hey-api/openapi-ts": "^0.66.2",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/source-map-support": "^0.5.10",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "^15.3.3",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"source-map-support": "^0.5.21",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
5
ui/postcss.config.mjs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
ui/public/axiom_icon.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg width='24' height='21' viewBox='0 0 24 21' fill='none' xmlns='http://www.w3.org/2000/svg'><path d='M23.8245 14.653L18.6977 5.97022C18.4626 5.57116 17.8833 5.24464 17.4104 5.24464H14.2097C13.4657 5.24464 13.1607 4.73173 13.5319 4.10482L15.287 1.13988C15.4264 0.904541 15.4261 0.614878 15.2862 0.379826C15.1464 0.144775 14.8884 0 14.6092 0H10.1441C9.67116 0 9.09058 0.325786 8.85393 0.72393L0.177602 15.3217C-0.0590538 15.7199 -0.0592297 16.3715 0.17725 16.7698L2.40972 20.5297C2.78169 21.1561 3.39173 21.1569 3.76528 20.5313L5.50971 17.6103C5.88327 16.9847 6.4933 16.9855 6.86527 17.6119L8.44675 20.2754C8.68317 20.6737 9.26369 20.9995 9.73659 20.9995H20.0544C20.5273 20.9995 21.1078 20.6737 21.3443 20.2754L23.8219 16.1028C24.0584 15.7045 24.0595 15.0522 23.8245 14.653ZM16.9008 14.2351C17.2704 14.8629 16.9642 15.3765 16.2202 15.3765H8.19478C7.45084 15.3765 7.14647 14.8639 7.51844 14.2375L11.5344 7.47417C11.9063 6.84771 12.515 6.84771 12.8869 7.47423L16.9008 14.2351Z' fill='#0D0D1F'/></svg>
|
||||
|
After Width: | Height: | Size: 1,000 B |
1
ui/public/file.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
ui/public/globe.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1 KiB |
9
ui/public/langfuse_icon.svg
Normal file
|
After Width: | Height: | Size: 14 KiB |
1
ui/public/next.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
ui/public/vercel.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
ui/public/window.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
23
ui/sentry.edge.config.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
|
||||
// The config you add here will be used whenever one of the edge features is loaded.
|
||||
// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
// Only initialize Sentry if explicitly enabled and DSN is provided
|
||||
const enableSentry = process.env.NEXT_PUBLIC_ENABLE_SENTRY === 'true' &&
|
||||
process.env.NEXT_PUBLIC_SENTRY_DSN;
|
||||
|
||||
if (enableSentry) {
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
enabled: process.env.NEXT_PUBLIC_NODE_ENV === 'production'
|
||||
});
|
||||
console.log('Sentry initialized for edge runtime error tracking');
|
||||
} else {
|
||||
console.log('Sentry disabled on edge runtime (NEXT_PUBLIC_ENABLE_SENTRY=false or DSN not configured)');
|
||||
}
|
||||
22
ui/sentry.server.config.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
// This file configures the initialization of Sentry on the server.
|
||||
// The config you add here will be used whenever the server handles a request.
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
// Only initialize Sentry if explicitly enabled and DSN is provided
|
||||
const enableSentry = process.env.NEXT_PUBLIC_ENABLE_SENTRY === 'true' &&
|
||||
process.env.NEXT_PUBLIC_SENTRY_DSN;
|
||||
|
||||
if (enableSentry) {
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
enabled: process.env.NEXT_PUBLIC_NODE_ENV === 'production'
|
||||
});
|
||||
console.log('Sentry initialized for server-side error tracking');
|
||||
} else {
|
||||
console.log('Sentry disabled on server (NEXT_PUBLIC_ENABLE_SENTRY=false or DSN not configured)');
|
||||
}
|
||||
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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
After Width: | Height: | Size: 24 KiB |
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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
export * from './useWorkflowState';
|
||||
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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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>
|
||||
|
||||
);
|
||||
}
|
||||
20
ui/src/client/client.gen.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { type ClientOptions as DefaultClientOptions, type Config, createClient, createConfig } from '@hey-api/client-fetch';
|
||||
|
||||
import { createClientConfig } from '../lib/apiClient';
|
||||
import type { ClientOptions } from './types.gen';
|
||||
|
||||
/**
|
||||
* The `createClientConfig()` function will be called on client initialization
|
||||
* and the returned object will become the client's initial configuration.
|
||||
*
|
||||
* You may want to initialize your client this way instead of calling
|
||||
* `setConfig()`. This is useful for example if you're using Next.js
|
||||
* to ensure your client always has the correct values.
|
||||
*/
|
||||
export type CreateClientConfig<T extends DefaultClientOptions = ClientOptions> = (override?: Config<DefaultClientOptions & T>) => Config<Required<DefaultClientOptions> & T>;
|
||||
|
||||
export const client = createClient(createClientConfig(createConfig<ClientOptions>({
|
||||
baseUrl: 'http://127.0.0.1:8000'
|
||||
})));
|
||||
3
ui/src/client/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
export * from './sdk.gen';
|
||||
export * from './types.gen';
|
||||
858
ui/src/client/sdk.gen.ts
Normal file
2621
ui/src/client/types.gen.ts
Normal file
115
ui/src/components/DailyUsageTable.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import type { DailyUsageBreakdownResponse } from '@/client/types.gen';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
|
||||
interface DailyUsageTableProps {
|
||||
data: DailyUsageBreakdownResponse | null;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function DailyUsageTable({ data, isLoading }: DailyUsageTableProps) {
|
||||
// Format date for display
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Daily Usage Breakdown</CardTitle>
|
||||
<CardDescription>Last 7 days of usage</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="animate-pulse space-y-3">
|
||||
{[...Array(7)].map((_, i) => (
|
||||
<div key={i} className="h-12 bg-gray-200 rounded"></div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || !data.breakdown || data.breakdown.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Daily Usage Breakdown</CardTitle>
|
||||
<CardDescription>Last 7 days of usage</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-center py-8 text-gray-500">No usage data available</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Daily Usage Breakdown</CardTitle>
|
||||
<CardDescription>Last 7 days of usage</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">Date</TableHead>
|
||||
<TableHead className="font-semibold text-right">Usage (minutes)</TableHead>
|
||||
<TableHead className="font-semibold text-right">Cost (USD)</TableHead>
|
||||
<TableHead className="font-semibold text-right">Calls</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.breakdown.map((day) => (
|
||||
<TableRow key={day.date}>
|
||||
<TableCell className="font-medium">
|
||||
{formatDate(day.date)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{day.minutes.toFixed(1)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
${(day.cost_usd || 0).toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{day.call_count}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow className="bg-gray-50 font-semibold">
|
||||
<TableCell>Total</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{data.total_minutes.toFixed(1)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
${(data.total_cost_usd || 0).toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{data.breakdown.reduce((sum, day) => sum + day.call_count, 0)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
158
ui/src/components/MediaPreviewDialog.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
'use client';
|
||||
|
||||
import { FileText, Loader2, Video } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { downloadFile, getSignedUrl } from '@/lib/files';
|
||||
|
||||
interface MediaPreviewDialogProps {
|
||||
accessToken: string | null;
|
||||
}
|
||||
|
||||
export function MediaPreviewDialog({ accessToken }: MediaPreviewDialogProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [mediaType, setMediaType] = useState<'audio' | 'transcript' | null>(null);
|
||||
const [mediaSignedUrl, setMediaSignedUrl] = useState<string | null>(null);
|
||||
const [selectedRunId, setSelectedRunId] = useState<number | null>(null);
|
||||
const [mediaDownloadKey, setMediaDownloadKey] = useState<string | null>(null);
|
||||
const [mediaLoading, setMediaLoading] = useState(false);
|
||||
|
||||
const openAudioModal = useCallback(
|
||||
async (fileKey: string | null, runId: number) => {
|
||||
if (!fileKey || !accessToken) return;
|
||||
setMediaLoading(true);
|
||||
const signed = await getSignedUrl(fileKey, accessToken);
|
||||
if (signed) {
|
||||
setMediaType('audio');
|
||||
setMediaSignedUrl(signed);
|
||||
setMediaDownloadKey(fileKey);
|
||||
setSelectedRunId(runId);
|
||||
setIsOpen(true);
|
||||
}
|
||||
setMediaLoading(false);
|
||||
},
|
||||
[accessToken],
|
||||
);
|
||||
|
||||
const openTranscriptModal = useCallback(
|
||||
async (fileKey: string | null, runId: number) => {
|
||||
if (!fileKey || !accessToken) return;
|
||||
setMediaLoading(true);
|
||||
const signed = await getSignedUrl(fileKey, accessToken, true);
|
||||
if (signed) {
|
||||
setMediaType('transcript');
|
||||
setMediaSignedUrl(signed);
|
||||
setMediaDownloadKey(fileKey);
|
||||
setSelectedRunId(runId);
|
||||
setIsOpen(true);
|
||||
}
|
||||
setMediaLoading(false);
|
||||
},
|
||||
[accessToken],
|
||||
);
|
||||
|
||||
return {
|
||||
openAudioModal,
|
||||
openTranscriptModal,
|
||||
dialog: (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{mediaType === 'audio' ? 'Recording Preview' : 'Transcript Preview'}
|
||||
{selectedRunId && ` - Run #${selectedRunId}`}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{mediaLoading && (
|
||||
<div className="flex items-center justify-center py-8 space-x-2">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!mediaLoading && mediaType === 'audio' && mediaSignedUrl && (
|
||||
<audio src={mediaSignedUrl} controls autoPlay className="w-full mt-4" />
|
||||
)}
|
||||
|
||||
{!mediaLoading && mediaType === 'transcript' && mediaSignedUrl && (
|
||||
<iframe
|
||||
src={mediaSignedUrl}
|
||||
title="Transcript"
|
||||
className="w-full h-[60vh] border rounded-md mt-4"
|
||||
/>
|
||||
)}
|
||||
|
||||
<DialogFooter className="pt-4">
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary">Close</Button>
|
||||
</DialogClose>
|
||||
{mediaDownloadKey && accessToken && (
|
||||
<Button onClick={() => downloadFile(mediaDownloadKey, accessToken)}>Download</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
interface MediaPreviewButtonsProps {
|
||||
recordingUrl: string | null | undefined;
|
||||
transcriptUrl: string | null | undefined;
|
||||
runId: number;
|
||||
onOpenAudio: (fileKey: string | null, runId: number) => void;
|
||||
onOpenTranscript: (fileKey: string | null, runId: number) => void;
|
||||
onSelect?: (runId: number) => void;
|
||||
}
|
||||
|
||||
export function MediaPreviewButtons({
|
||||
recordingUrl,
|
||||
transcriptUrl,
|
||||
runId,
|
||||
onOpenAudio,
|
||||
onOpenTranscript,
|
||||
onSelect,
|
||||
}: MediaPreviewButtonsProps) {
|
||||
const handleOpenAudio = () => {
|
||||
onSelect?.(runId);
|
||||
onOpenAudio(recordingUrl ?? null, runId);
|
||||
};
|
||||
|
||||
const handleOpenTranscript = () => {
|
||||
onSelect?.(runId);
|
||||
onOpenTranscript(transcriptUrl ?? null, runId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex space-x-2">
|
||||
{recordingUrl && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleOpenAudio}
|
||||
>
|
||||
<Video className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{transcriptUrl && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleOpenTranscript}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
ui/src/components/PostHogIdentify.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
'use client';
|
||||
|
||||
import posthog from 'posthog-js';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useAuth } from '@/lib/auth';
|
||||
|
||||
/**
|
||||
* PostHogIdentify
|
||||
* ---------------
|
||||
* A tiny client-side component that calls `posthog.identify` once the
|
||||
* authenticated user object is available. It also resets PostHog when the
|
||||
* user logs out or switches accounts.
|
||||
*
|
||||
* This component is intended to be rendered high in the React tree (e.g. in
|
||||
* `app/layout.tsx`) so that PostHog always knows which user is active for the
|
||||
* current browser session.
|
||||
*/
|
||||
export default function PostHogIdentify() {
|
||||
const { user } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
// Only run if PostHog is enabled
|
||||
if (process.env.NEXT_PUBLIC_ENABLE_POSTHOG !== 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (user) {
|
||||
try {
|
||||
// Identify the user in PostHog with their unique id and useful traits
|
||||
posthog.identify(String(user.id ?? ''));
|
||||
} catch (err) {
|
||||
// Silently ignore identification errors so they don't break the app
|
||||
|
||||
console.warn('Failed to identify user in PostHog', err);
|
||||
}
|
||||
} else {
|
||||
// If the user logs out, clear the PostHog identity so future anonymous
|
||||
// interactions aren't associated with the previous account.
|
||||
posthog.reset();
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
// This component does not render anything
|
||||
return null;
|
||||
}
|
||||
340
ui/src/components/ServiceConfiguration.tsx
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { getDefaultConfigurationsApiV1UserConfigurationsDefaultsGet } from '@/client/sdk.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 { useUserConfig } from "@/context/UserConfigContext";
|
||||
|
||||
type ServiceSegment = "llm" | "tts" | "stt";
|
||||
|
||||
interface SchemaProperty {
|
||||
type?: string;
|
||||
default?: string | number | boolean;
|
||||
enum?: string[];
|
||||
$ref?: string;
|
||||
description?: string;
|
||||
format?: string;
|
||||
}
|
||||
|
||||
interface ProviderSchema {
|
||||
properties: Record<string, SchemaProperty>;
|
||||
required?: string[];
|
||||
$defs?: Record<string, SchemaProperty>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface FormValues {
|
||||
[key: string]: string | number | boolean;
|
||||
}
|
||||
|
||||
export default function ServiceConfiguration() {
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { userConfig, saveUserConfig } = useUserConfig();
|
||||
const [schemas, setSchemas] = useState<Record<ServiceSegment, Record<string, ProviderSchema>>>({
|
||||
llm: {},
|
||||
tts: {},
|
||||
stt: {}
|
||||
});
|
||||
const [serviceProviders, setServiceProviders] = useState<Record<ServiceSegment, string>>({
|
||||
llm: "",
|
||||
tts: "",
|
||||
stt: ""
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
reset,
|
||||
getValues,
|
||||
setValue,
|
||||
watch
|
||||
} = useForm();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchConfigurations = async () => {
|
||||
const response = await getDefaultConfigurationsApiV1UserConfigurationsDefaultsGet();
|
||||
if (response.data) {
|
||||
setSchemas({
|
||||
llm: response.data.llm as Record<string, ProviderSchema>,
|
||||
tts: response.data.tts as Record<string, ProviderSchema>,
|
||||
stt: response.data.stt as Record<string, ProviderSchema>
|
||||
});
|
||||
} else {
|
||||
console.error("Failed to fetch configurations");
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultValues: Record<string, string | number | boolean> = {};
|
||||
const selectedProviders: Record<ServiceSegment, string> = {
|
||||
llm: response.data.default_providers.llm,
|
||||
tts: response.data.default_providers.tts,
|
||||
stt: response.data.default_providers.stt
|
||||
};
|
||||
|
||||
const setServicePropertyValues = (service: ServiceSegment) => {
|
||||
/*
|
||||
sets service properties like api_key, model etc. from default configurations
|
||||
if not present in user configurations
|
||||
|
||||
service - llm/ tts/ stt
|
||||
|
||||
|
||||
userConfig['llm'] = {
|
||||
provider: 'openai',
|
||||
api_key: 'sk-...'
|
||||
}
|
||||
|
||||
response.data.llm = {
|
||||
openai: {
|
||||
properties: {
|
||||
provider: 'openai'
|
||||
api_key: 'sk-...'
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
if (userConfig?.[service]?.provider) {
|
||||
Object.entries(userConfig?.[service]).forEach(([field, value]) => {
|
||||
if (field !== "provider") {
|
||||
defaultValues[`${service}_${field}`] = value;
|
||||
}
|
||||
});
|
||||
selectedProviders[service] = userConfig?.[service]?.provider as string;
|
||||
} else {
|
||||
// response.data['service'] will all providers for the given service
|
||||
// selectedProviders[service] will have the provider name
|
||||
const properties = response.data[service]?.[selectedProviders[service]]?.properties as Record<string, SchemaProperty>;
|
||||
if (properties) {
|
||||
Object.entries(properties).forEach(([field, schema]) => {
|
||||
if (field !== "provider" && schema.default) {
|
||||
defaultValues[`${service}_${field}`] = schema.default;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setServicePropertyValues("llm");
|
||||
setServicePropertyValues("tts");
|
||||
setServicePropertyValues("stt");
|
||||
|
||||
setServiceProviders(selectedProviders);
|
||||
|
||||
reset(defaultValues);
|
||||
};
|
||||
fetchConfigurations();
|
||||
}, [reset, userConfig]);
|
||||
|
||||
const handleProviderChange = (service: ServiceSegment, providerName: string) => {
|
||||
/*
|
||||
service can be llm/ tts/ stt
|
||||
providerName is openAI/ Deepgram etc.
|
||||
*/
|
||||
if (!providerName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentValues = getValues();
|
||||
const preservedValues: Record<string, string | number | boolean> = {};
|
||||
|
||||
// Preserve values from other services
|
||||
Object.keys(currentValues).forEach(key => {
|
||||
if (!key.startsWith(`${service}_`)) {
|
||||
preservedValues[key] = currentValues[key];
|
||||
}
|
||||
});
|
||||
|
||||
// Set default values from schema
|
||||
if (schemas?.[service]?.[providerName]) {
|
||||
const providerSchema = schemas[service][providerName];
|
||||
Object.entries(providerSchema.properties).forEach(([field, schema]: [string, SchemaProperty]) => {
|
||||
if (field !== "provider" && schema.default !== undefined) {
|
||||
preservedValues[`${service}_${field}`] = schema.default;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
preservedValues[`${service}_provider`] = providerName;
|
||||
reset(preservedValues);
|
||||
setServiceProviders(prev => ({ ...prev, [service]: providerName }));
|
||||
}
|
||||
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
/*
|
||||
data contains form values like llm_api_key: "sk...", llm_model: "gpt-4o" etc.
|
||||
extract the values in relevant form
|
||||
*/
|
||||
setApiError(null);
|
||||
setIsSaving(true);
|
||||
|
||||
const userConfig = {
|
||||
llm: {
|
||||
provider: serviceProviders.llm,
|
||||
api_key: data.llm_api_key as string,
|
||||
model: data.llm_model as string
|
||||
},
|
||||
tts: {
|
||||
provider: serviceProviders.tts,
|
||||
api_key: data.tts_api_key as string
|
||||
},
|
||||
stt: {
|
||||
provider: serviceProviders.stt,
|
||||
api_key: data.stt_api_key as string
|
||||
}
|
||||
};
|
||||
|
||||
// Add any extra properties in the payload
|
||||
Object.entries(data).forEach(([property, value]) => {
|
||||
const parts = property.split('_');
|
||||
const service = parts[0] as ServiceSegment;
|
||||
const field = parts.slice(1).join('_'); // Join all parts after the service name
|
||||
|
||||
if (userConfig[service] && !(field in userConfig[service])) {
|
||||
(userConfig[service] as Record<string, string>)[field] = value as string;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await saveUserConfig({
|
||||
llm: userConfig.llm,
|
||||
tts: userConfig.tts,
|
||||
stt: userConfig.stt
|
||||
});
|
||||
setApiError(null);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
setApiError(error.message);
|
||||
} else {
|
||||
setApiError('An unknown error occurred');
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderServiceSegmentFields = (service: ServiceSegment) => {
|
||||
// Segment is segments like llm, tts and stt
|
||||
const currentProvider = serviceProviders[service];
|
||||
const providerSchema = schemas?.[service]?.[currentProvider];
|
||||
const availableProviders = schemas?.[service] ? Object.keys(schemas[service]) : [];
|
||||
|
||||
return (
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>{service.toUpperCase()} Configuration</CardTitle>
|
||||
<CardDescription>
|
||||
Configure your {service.toUpperCase()} service
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Provider</Label>
|
||||
<Select
|
||||
value={currentProvider}
|
||||
onValueChange={(providerName) => {
|
||||
handleProviderChange(service, providerName);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={`Select ${service.toUpperCase()} provider`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableProviders.map((provider) => (
|
||||
<SelectItem key={provider} value={provider}>
|
||||
{provider}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{currentProvider && providerSchema && (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(providerSchema.properties).map(([field, schema]: [string, SchemaProperty]) => {
|
||||
// Handle $ref fields by getting the actual schema from $defs
|
||||
const actualSchema = schema.$ref && providerSchema.$defs
|
||||
? providerSchema.$defs[schema.$ref.split('/').pop() || '']
|
||||
: schema;
|
||||
|
||||
// Skip provider field as it's handled separately
|
||||
return field !== "provider" && (
|
||||
<div key={`${service}_${field}_${currentProvider}`} className="space-y-2">
|
||||
<Label>{field}</Label>
|
||||
{actualSchema?.enum ? (
|
||||
<Select
|
||||
value={watch(`${service}_${field}`) as string || ""}
|
||||
onValueChange={(value) => {
|
||||
setValue(`${service}_${field}`, value, { shouldDirty: true });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={`Select ${field}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{actualSchema.enum.map((value: string) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
type={actualSchema?.type === "number" ? "number" : "text"}
|
||||
{...(actualSchema?.type === "number" && { step: "any" })}
|
||||
placeholder={`Enter ${field}`}
|
||||
{...register(`${service}_${field}`, {
|
||||
required: providerSchema.required?.includes(field),
|
||||
valueAsNumber: actualSchema?.type === "number"
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{errors[`${service}_${field}`] && (
|
||||
<p className="text-sm text-red-500">
|
||||
{typeof errors[`${service}_${field}`]?.message === 'string'
|
||||
? String(errors[`${service}_${field}`]?.message)
|
||||
: "This field is required"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-4xl mx-auto py-8">
|
||||
<h1 className="text-2xl font-bold mb-6">Service Configuration</h1>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
{renderServiceSegmentFields("llm")}
|
||||
{renderServiceSegmentFields("tts")}
|
||||
{renderServiceSegmentFields("stt")}
|
||||
|
||||
{apiError && <p className="text-red-500">{apiError}</p>}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isSaving}>
|
||||
{isSaving ? "Saving..." : "Save Configuration"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
ui/src/components/SignInClient.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"use client";
|
||||
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
// Only load Stack's SignIn component when Stack provider is active
|
||||
const SignIn = dynamic(
|
||||
() => import('@stackframe/stack').then(mod => ({ default: mod.SignIn })),
|
||||
{ ssr: false, loading: () => <Loader2 className="w-5 h-5 animate-spin text-gray-600" /> }
|
||||
);
|
||||
|
||||
export default function SignInClient() {
|
||||
const authProvider = process.env.NEXT_PUBLIC_AUTH_PROVIDER || 'stack';
|
||||
|
||||
if (authProvider !== 'stack') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold mb-4">Local Authentication</h1>
|
||||
<p className="text-gray-600">Local authentication is enabled. No sign-in required.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <SignIn />;
|
||||
}
|
||||
9
ui/src/components/SpinLoader.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export default function SpinLoader(){
|
||||
return(
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Loader2 className="w-15 h-15 animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
29
ui/src/components/ThemeSwitcher.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"use client";
|
||||
|
||||
import { Moon,Sun } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const [theme, setTheme] = useState("light");
|
||||
|
||||
useEffect(() => {
|
||||
const storedTheme = localStorage.getItem("theme") || "light";
|
||||
setTheme(storedTheme);
|
||||
document.documentElement.classList.toggle("dark", storedTheme === "dark");
|
||||
}, []);
|
||||
|
||||
const toggleTheme = () => {
|
||||
const newTheme = theme === "light" ? "dark" : "light";
|
||||
setTheme(newTheme);
|
||||
localStorage.setItem("theme", newTheme);
|
||||
document.documentElement.classList.toggle("dark", newTheme === "dark");
|
||||
};
|
||||
|
||||
return (
|
||||
<Button variant="outline" className="absolute top-4 right-4" onClick={toggleTheme}>
|
||||
{theme === "light" ? <Moon className="h-5 w-5" /> : <Sun className="h-5 w-5" />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
169
ui/src/components/filters/DateRangeFilter.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import { CalendarIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { getDatePresetValue } from "@/lib/filters";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DateRangeValue } from "@/types/filters";
|
||||
|
||||
interface DateRangeFilterProps {
|
||||
value: DateRangeValue;
|
||||
onChange: (value: DateRangeValue) => void;
|
||||
error?: string;
|
||||
presets?: string[];
|
||||
}
|
||||
|
||||
export const DateRangeFilter: React.FC<DateRangeFilterProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
presets = [],
|
||||
}) => {
|
||||
const [isFromOpen, setIsFromOpen] = useState(false);
|
||||
const [isToOpen, setIsToOpen] = useState(false);
|
||||
|
||||
const formatDate = (date: Date | null) => {
|
||||
if (!date) return "Select date";
|
||||
return date.toLocaleDateString() + " " + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
const handlePresetClick = (preset: string) => {
|
||||
const presetValue = getDatePresetValue(preset);
|
||||
onChange(presetValue);
|
||||
};
|
||||
|
||||
const handleFromChange = (date: Date | undefined) => {
|
||||
if (date) {
|
||||
// Keep the time from the existing date if available
|
||||
if (value.from) {
|
||||
date.setHours(value.from.getHours(), value.from.getMinutes());
|
||||
}
|
||||
onChange({ ...value, from: date });
|
||||
}
|
||||
setIsFromOpen(false);
|
||||
};
|
||||
|
||||
const handleToChange = (date: Date | undefined) => {
|
||||
if (date) {
|
||||
// Set to end of day by default
|
||||
date.setHours(23, 59, 59, 999);
|
||||
onChange({ ...value, to: date });
|
||||
}
|
||||
setIsToOpen(false);
|
||||
};
|
||||
|
||||
const handleTimeChange = (type: 'from' | 'to', timeString: string) => {
|
||||
const [hours, minutes] = timeString.split(':').map(Number);
|
||||
const date = type === 'from' ? value.from : value.to;
|
||||
if (date) {
|
||||
const newDate = new Date(date);
|
||||
newDate.setHours(hours, minutes);
|
||||
onChange({ ...value, [type]: newDate });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{presets.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{presets.map((preset) => (
|
||||
<Button
|
||||
key={preset}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePresetClick(preset)}
|
||||
>
|
||||
{preset.charAt(0).toUpperCase() + preset.slice(1).replace(/(\d+)/, ' $1 ')}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label>From</Label>
|
||||
<Popover open={isFromOpen} onOpenChange={setIsFromOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal",
|
||||
!value.from && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{formatDate(value.from)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={value.from || undefined}
|
||||
onSelect={handleFromChange}
|
||||
initialFocus
|
||||
/>
|
||||
{value.from && (
|
||||
<div className="p-3 border-t">
|
||||
<Label htmlFor="from-time">Time</Label>
|
||||
<input
|
||||
id="from-time"
|
||||
type="time"
|
||||
className="w-full mt-1 px-3 py-2 border rounded-md"
|
||||
value={value.from.toTimeString().slice(0, 5)}
|
||||
onChange={(e) => handleTimeChange('from', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>To</Label>
|
||||
<Popover open={isToOpen} onOpenChange={setIsToOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal",
|
||||
!value.to && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{formatDate(value.to)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={value.to || undefined}
|
||||
onSelect={handleToChange}
|
||||
initialFocus
|
||||
disabled={(date) => value.from ? date < value.from : false}
|
||||
/>
|
||||
{value.to && (
|
||||
<div className="p-3 border-t">
|
||||
<Label htmlFor="to-time">Time</Label>
|
||||
<input
|
||||
id="to-time"
|
||||
type="time"
|
||||
className="w-full mt-1 px-3 py-2 border rounded-md"
|
||||
value={value.to.toTimeString().slice(0, 5)}
|
||||
onChange={(e) => handleTimeChange('to', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-500 mt-1">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||