mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-01 08:59:46 +02:00
feat: banner if API is not reachable
This commit is contained in:
parent
ba342b66a7
commit
78ba62e185
15 changed files with 181 additions and 65 deletions
|
|
@ -1,32 +1,64 @@
|
|||
import { NextResponse } from "next/server";
|
||||
|
||||
import { healthApiV1HealthGet } from "@/client/sdk.gen";
|
||||
import type { HealthResponse } from "@/client/types.gen";
|
||||
import { getServerBackendUrl } from "@/lib/apiClient";
|
||||
|
||||
// Import version from package.json at build time
|
||||
import packageJson from "../../../../../package.json";
|
||||
|
||||
const HEALTHCHECK_TIMEOUT_MS = 3000;
|
||||
|
||||
function trimTrailingSlash(url: string) {
|
||||
return url.endsWith("/") ? url.slice(0, -1) : url;
|
||||
}
|
||||
|
||||
function getHealthcheckFailureMessage(error: unknown, backendUrl: string) {
|
||||
const errorName =
|
||||
error && typeof error === "object" && "name" in error
|
||||
? String((error as { name?: unknown }).name)
|
||||
: "";
|
||||
|
||||
if (errorName === "AbortError" || errorName === "TimeoutError") {
|
||||
return `Backend health check timed out after ${HEALTHCHECK_TIMEOUT_MS}ms while trying to reach ${backendUrl}.`;
|
||||
}
|
||||
|
||||
return `Backend is not reachable at ${backendUrl}.`;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const uiVersion = packageJson.version || "dev";
|
||||
const backendUrl = trimTrailingSlash(getServerBackendUrl());
|
||||
const healthcheckUrl = `${backendUrl}/api/v1/health`;
|
||||
|
||||
let apiVersion = "unknown";
|
||||
let deploymentMode = "oss";
|
||||
let authProvider = "local";
|
||||
let turnEnabled = false;
|
||||
let forceTurnRelay = false;
|
||||
let backendStatus: "reachable" | "unreachable" = "unreachable";
|
||||
let backendMessage: string | null = `Backend is not reachable at ${backendUrl}.`;
|
||||
|
||||
try {
|
||||
const response = await healthApiV1HealthGet();
|
||||
if (response.data) {
|
||||
const data = response.data as HealthResponse;
|
||||
const response = await fetch(healthcheckUrl, {
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(HEALTHCHECK_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
backendMessage = `Backend health check at ${healthcheckUrl} returned HTTP ${response.status}.`;
|
||||
} else {
|
||||
const data = (await response.json()) as HealthResponse;
|
||||
apiVersion = data.version;
|
||||
deploymentMode = data.deployment_mode;
|
||||
authProvider = data.auth_provider;
|
||||
turnEnabled = Boolean(data.turn_enabled);
|
||||
forceTurnRelay = Boolean(data.force_turn_relay);
|
||||
backendStatus = "reachable";
|
||||
backendMessage = null;
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
apiVersion = "unavailable";
|
||||
backendMessage = getHealthcheckFailureMessage(error, backendUrl);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
|
|
@ -36,5 +68,11 @@ export async function GET() {
|
|||
authProvider,
|
||||
turnEnabled,
|
||||
forceTurnRelay,
|
||||
backend: {
|
||||
status: backendStatus,
|
||||
url: backendUrl,
|
||||
healthcheckUrl,
|
||||
message: backendMessage,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { AlertCircle } from "lucide-react";
|
||||
|
||||
import type { RecordingResponseSchema } from "@/client/types.gen";
|
||||
import { RecordingSelect } from "@/components/flow/TextOrAudioInput";
|
||||
import { RecordingSelect, StaticTextWarning } from "@/components/flow/TextOrAudioInput";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -144,10 +142,7 @@ export function EndCallToolConfig({
|
|||
</div>
|
||||
{messageType === "custom" && (
|
||||
<div className="pl-8 space-y-2">
|
||||
<div className="flex items-start gap-2 rounded-md bg-amber-50 p-2 text-xs text-amber-700 border border-amber-200">
|
||||
<AlertCircle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
||||
<span>This text is spoken as-is. For multilingual workflows, choose your phrasing carefully.</span>
|
||||
</div>
|
||||
<StaticTextWarning />
|
||||
<Textarea
|
||||
value={customMessage}
|
||||
onChange={(e) => onCustomMessageChange(e.target.value)}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { AlertCircle } from "lucide-react";
|
||||
|
||||
import type { RecordingResponseSchema } from "@/client/types.gen";
|
||||
import { TextOrAudioInput } from "@/components/flow/TextOrAudioInput";
|
||||
import { StaticTextWarning, TextOrAudioInput } from "@/components/flow/TextOrAudioInput";
|
||||
import {
|
||||
CredentialSelector,
|
||||
type HttpMethod,
|
||||
|
|
@ -164,10 +162,7 @@ export function HttpApiToolConfig({
|
|||
recordings={recordings}
|
||||
>
|
||||
<>
|
||||
<div className="flex items-start gap-2 rounded-md bg-amber-50 p-2 text-xs text-amber-700 border border-amber-200">
|
||||
<AlertCircle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
||||
<span>This text is spoken as-is. For multilingual workflows, choose your phrasing carefully.</span>
|
||||
</div>
|
||||
<StaticTextWarning />
|
||||
<Textarea
|
||||
value={customMessage}
|
||||
onChange={(e) => onCustomMessageChange(e.target.value)}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import {useState } from "react";
|
||||
|
||||
import type { RecordingResponseSchema } from "@/client/types.gen";
|
||||
import { RecordingSelect } from "@/components/flow/TextOrAudioInput";
|
||||
import { RecordingSelect, StaticTextWarning } from "@/components/flow/TextOrAudioInput";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -177,10 +176,7 @@ export function TransferCallToolConfig({
|
|||
</div>
|
||||
{messageType === "custom" && (
|
||||
<div className="pl-8 space-y-2">
|
||||
<div className="flex items-start gap-2 rounded-md bg-amber-50 p-2 text-xs text-amber-700 border border-amber-200">
|
||||
<AlertCircle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
||||
<span>This text is spoken as-is. For multilingual workflows, choose your phrasing carefully.</span>
|
||||
</div>
|
||||
<StaticTextWarning />
|
||||
<Textarea
|
||||
value={customMessage}
|
||||
onChange={(e) => onCustomMessageChange(e.target.value)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Check, ChevronDown, Pause, Play, Search } from "lucide-react";
|
||||
import { AlertCircle, Check, ChevronDown, Pause, Play, Search } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import type { RecordingResponseSchema } from "@/client/types.gen";
|
||||
|
|
@ -10,6 +10,25 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|||
import { useAudioPlayback } from "@/hooks/useAudioPlayback";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Amber caveat shown next to free-text fields that are spoken aloud via TTS
|
||||
* (greetings, transition speech, custom tool messages). Two warnings: the text
|
||||
* is voiced verbatim (matters for multilingual flows), and realtime
|
||||
* (speech-to-speech) models have no TTS stage, so static text is never spoken —
|
||||
* a pre-recorded audio file should be used instead.
|
||||
*/
|
||||
export function StaticTextWarning() {
|
||||
return (
|
||||
<div className="flex items-start gap-2 rounded-md bg-amber-50 p-2 text-xs text-amber-700 border border-amber-200">
|
||||
<AlertCircle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
||||
<span>
|
||||
This text is spoken as-is. For multilingual workflows, choose your phrasing carefully.
|
||||
Realtime (speech-to-speech) models can't play static text.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TextOrAudioInputProps {
|
||||
type: 'text' | 'audio';
|
||||
onTypeChange: (type: 'text' | 'audio') => void;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useCallback, useEffect, useState } from 'react';
|
|||
|
||||
import { useWorkflow, useWorkflowOptional } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
|
||||
import { useWorkflowStore } from "@/app/workflow/[workflowId]/stores/workflowStore";
|
||||
import { TextOrAudioInput } from "@/components/flow/TextOrAudioInput";
|
||||
import { StaticTextWarning, TextOrAudioInput } from "@/components/flow/TextOrAudioInput";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -122,10 +122,7 @@ const EdgeDetailsDialog = ({ open, onOpenChange, data, onSave }: EdgeDetailsDial
|
|||
recordings={recordings ?? []}
|
||||
>
|
||||
<>
|
||||
<div className="flex items-start gap-2 rounded-md bg-amber-50 p-2 text-xs text-amber-700 border border-amber-200">
|
||||
<AlertCircle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
||||
<span>This text is spoken as-is. For multilingual workflows, choose your phrasing carefully.</span>
|
||||
</div>
|
||||
<StaticTextWarning />
|
||||
<Textarea
|
||||
value={transitionSpeech}
|
||||
placeholder="e.g. Let me transfer you to our billing department..."
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { Menu } from "lucide-react";
|
||||
import { AlertTriangle, Menu, RefreshCw } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import posthog from "posthog-js";
|
||||
|
|
@ -9,6 +9,7 @@ import React, { ReactNode } from "react";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { SidebarInset, SidebarProvider, useSidebar } from "@/components/ui/sidebar";
|
||||
import { PostHogEvent } from "@/constants/posthog-events";
|
||||
import { useAppConfig } from "@/context/AppConfigContext";
|
||||
|
||||
import { AppSidebar } from "./AppSidebar";
|
||||
import { GitHubStarBadge } from "./GitHubStarBadge";
|
||||
|
|
@ -27,7 +28,7 @@ function AppHeader() {
|
|||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<a
|
||||
href="https://join.slack.com/t/dograh-community/shared_invite/zt-3czr47sw5-MSg1J0kJ7IMPOCHF~03auQ"
|
||||
href="https://join.slack.com/t/dograh-community/shared_invite/zt-3zjb5vwvl-j7hRz3_F1SOn5cH~jm5f5g"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => posthog.capture(PostHogEvent.SLACK_COMMUNITY_CLICKED, { source: "app_header" })}
|
||||
|
|
@ -45,6 +46,46 @@ function AppHeader() {
|
|||
);
|
||||
}
|
||||
|
||||
function BackendStatusBanner() {
|
||||
const { config, loading, refresh } = useAppConfig();
|
||||
|
||||
if (!config || config.backendStatus === "reachable") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const backendUrl = config.backendUrl && config.backendUrl !== "unknown"
|
||||
? config.backendUrl
|
||||
: "the configured backend";
|
||||
const message = config.backendMessage || `Backend is not reachable at ${backendUrl}.`;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="border-b border-amber-300 bg-amber-50 px-4 py-3 text-amber-950 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-100"
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold">Backend connection failed</p>
|
||||
<p className="break-words text-sm">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void refresh()}
|
||||
disabled={loading}
|
||||
className="h-8 shrink-0 border-amber-400 bg-transparent text-amber-950 hover:bg-amber-100 dark:border-amber-700 dark:text-amber-100 dark:hover:bg-amber-900/40"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AppLayoutProps {
|
||||
children: ReactNode;
|
||||
headerActions?: ReactNode;
|
||||
|
|
@ -73,6 +114,7 @@ const AppLayout: React.FC<AppLayoutProps> = ({
|
|||
<div className="flex min-h-screen w-full">
|
||||
<AppSidebar />
|
||||
<SidebarInset className="flex-1">
|
||||
<BackendStatusBanner />
|
||||
{!isWorkflowEditor && <AppHeader />}
|
||||
{/* Optional header area for specific pages */}
|
||||
{headerActions && (
|
||||
|
|
@ -104,6 +146,7 @@ const AppLayout: React.FC<AppLayoutProps> = ({
|
|||
</div>
|
||||
) : (
|
||||
<div className="flex-1 w-full">
|
||||
<BackendStatusBanner />
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
'use client';
|
||||
|
||||
import { createContext, ReactNode, useContext, useEffect, useState } from 'react';
|
||||
import { createContext, ReactNode, useCallback, useContext, useEffect, useState } from 'react';
|
||||
|
||||
type BackendStatus = 'reachable' | 'unreachable';
|
||||
|
||||
interface AppConfig {
|
||||
uiVersion: string;
|
||||
|
|
@ -9,54 +11,80 @@ interface AppConfig {
|
|||
authProvider: string;
|
||||
turnEnabled: boolean;
|
||||
forceTurnRelay: boolean;
|
||||
backendStatus: BackendStatus;
|
||||
backendUrl: string;
|
||||
backendMessage: string | null;
|
||||
}
|
||||
|
||||
interface AppConfigContextType {
|
||||
config: AppConfig | null;
|
||||
loading: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
const defaultConfig: AppConfig = {
|
||||
uiVersion: 'dev',
|
||||
apiVersion: 'unknown',
|
||||
apiVersion: 'unavailable',
|
||||
deploymentMode: 'oss',
|
||||
authProvider: 'local',
|
||||
turnEnabled: false,
|
||||
forceTurnRelay: false,
|
||||
backendStatus: 'unreachable',
|
||||
backendUrl: process.env.NEXT_PUBLIC_BACKEND_URL || 'unknown',
|
||||
backendMessage: process.env.NEXT_PUBLIC_BACKEND_URL
|
||||
? `Unable to verify backend health at ${process.env.NEXT_PUBLIC_BACKEND_URL}.`
|
||||
: 'Unable to verify backend health.',
|
||||
};
|
||||
|
||||
const AppConfigContext = createContext<AppConfigContextType>({
|
||||
config: null,
|
||||
loading: true,
|
||||
refresh: async () => { },
|
||||
});
|
||||
|
||||
export function AppConfigProvider({ children }: { children: ReactNode }) {
|
||||
const [config, setConfig] = useState<AppConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/config/version')
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setConfig({
|
||||
uiVersion: data.ui || 'dev',
|
||||
apiVersion: data.api || 'unknown',
|
||||
deploymentMode: data.deploymentMode || 'oss',
|
||||
authProvider: data.authProvider || 'local',
|
||||
turnEnabled: Boolean(data.turnEnabled),
|
||||
forceTurnRelay: Boolean(data.forceTurnRelay),
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setConfig(defaultConfig);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
const loadConfig = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/config/version', { cache: 'no-store' });
|
||||
const data = await response.json();
|
||||
const backend = data.backend && typeof data.backend === 'object' ? data.backend : {};
|
||||
const backendStatus: BackendStatus = backend.status === 'reachable' ? 'reachable' : 'unreachable';
|
||||
const backendUrl = typeof backend.url === 'string' && backend.url.length > 0
|
||||
? backend.url
|
||||
: defaultConfig.backendUrl;
|
||||
|
||||
setConfig({
|
||||
uiVersion: data.ui || 'dev',
|
||||
apiVersion: data.api || 'unknown',
|
||||
deploymentMode: data.deploymentMode || 'oss',
|
||||
authProvider: data.authProvider || 'local',
|
||||
turnEnabled: Boolean(data.turnEnabled),
|
||||
forceTurnRelay: Boolean(data.forceTurnRelay),
|
||||
backendStatus,
|
||||
backendUrl,
|
||||
backendMessage: typeof backend.message === 'string' && backend.message.length > 0
|
||||
? backend.message
|
||||
: backendStatus === 'reachable'
|
||||
? null
|
||||
: `Backend is not reachable at ${backendUrl}.`,
|
||||
});
|
||||
} catch {
|
||||
setConfig(defaultConfig);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, [loadConfig]);
|
||||
|
||||
return (
|
||||
<AppConfigContext.Provider value={{ config, loading }}>
|
||||
<AppConfigContext.Provider value={{ config, loading, refresh: loadConfig }}>
|
||||
{children}
|
||||
</AppConfigContext.Provider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,17 @@
|
|||
import type { Client } from '@/client/client';
|
||||
import type { CreateClientConfig } from '@/client/client.gen';
|
||||
|
||||
export function getServerBackendUrl() {
|
||||
return process.env.BACKEND_URL || 'http://api:8000';
|
||||
}
|
||||
|
||||
export const createClientConfig: CreateClientConfig = (config) => {
|
||||
// Use different URLs for server-side vs client-side
|
||||
const isServer = typeof window === 'undefined';
|
||||
let baseUrl: string;
|
||||
|
||||
if (isServer) {
|
||||
baseUrl = process.env.BACKEND_URL || 'http://api:8000';
|
||||
baseUrl = getServerBackendUrl();
|
||||
} else {
|
||||
baseUrl = process.env.NEXT_PUBLIC_BACKEND_URL || window.location.origin;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue