fix: comprehensive QA — resolve 13 bugs, add UX improvements across all services

Client SDK: add .catch() to graphRagStreaming/documentRagStreaming (silent timeout),
null-guard JSON.parse in getPrompts/getSystemPrompt/getPrompt.

Backend: implement "getvalues" config operation for token costs, null-check
createTerm() in FalkorDB triples query, add knowledge-cores service entrypoint
and Docker entry, return proper HTTP 400/404 for gateway error responses.

Workbench: cancel button + elapsed timer for chat, clear agent spinner on error,
flow dialog inline validation, responsive header wrapping, knowledge cores
loading timeout, sidebar/page naming consistency, theme toggle indicator.

Infrastructure: enable Grafana Explore for viewers, add gateway Prometheus
scrape target, fix RAG pipeline dashboard layout (6 panels visible),
filter Service Health to configured targets only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
elpresidank 2026-04-07 05:20:10 -05:00
parent 72870a7e2e
commit 3a80872482
22 changed files with 202 additions and 54 deletions

View file

@ -160,7 +160,7 @@ export function Sidebar() {
<NavItem to="/graph" icon={Rotate3d} label="Graph" />
<NavItem to="/prompts" icon={MessageCircleCode} label="Prompts" />
<NavItem to="/token-cost" icon={Coins} label="Token Cost" />
<NavItem to="/knowledge-cores" icon={BrainCircuit} label="Knowledge" />
<NavItem to="/knowledge-cores" icon={BrainCircuit} label="Knowledge Cores" />
<NavItem to="/flows" icon={Workflow} label="Flows" />
<NavItem to="/settings" icon={Settings} label="Settings" />
</nav>

View file

@ -1,4 +1,4 @@
import { useCallback } from "react";
import { useCallback, useRef } from "react";
import { useSocket } from "@/providers/socket-provider";
import {
useConversation,
@ -16,6 +16,7 @@ import type { StreamingMetadata } from "@trustgraph/client";
export interface UseChatReturn {
submitMessage: (opts: { input: string }) => void;
cancelRequest: () => void;
}
/**
@ -33,10 +34,32 @@ export function useChat(): UseChatReturn {
const addActivity = useProgressStore((s) => s.addActivity);
const removeActivity = useProgressStore((s) => s.removeActivity);
const abortControllerRef = useRef<AbortController | null>(null);
const cancelRequest = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
updateLastMessage((prev) => ({
...prev,
content: prev.content || "(Cancelled)",
isStreaming: false,
activePhase: undefined,
}));
removeActivity("Chat request");
}, [updateLastMessage, removeActivity]);
const submitMessage = useCallback(
({ input }: { input: string }) => {
if (!input.trim()) return;
// Abort any in-flight request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
const activityLabel = "Chat request";
// 1. Add the user message
@ -101,6 +124,7 @@ export function useChat(): UseChatReturn {
...prev,
content: prev.content || `Error: ${error}`,
isStreaming: false,
activePhase: undefined,
}));
removeActivity(activityLabel);
};
@ -211,5 +235,5 @@ export function useChat(): UseChatReturn {
],
);
return { submitMessage };
return { submitMessage, cancelRequest };
}

View file

@ -15,6 +15,7 @@ import {
ChevronDown,
ChevronRight,
Loader2,
X,
} from "lucide-react";
import Markdown from "react-markdown";
import { cn } from "@/lib/utils";
@ -189,12 +190,23 @@ export default function ChatPage() {
const setInput = useConversation((s) => s.setInput);
const setChatMode = useConversation((s) => s.setChatMode);
const clearMessages = useConversation((s) => s.clearMessages);
const { submitMessage } = useChat();
const { submitMessage, cancelRequest } = useChat();
const collection = useSettings((s) => s.settings.collection);
const isLoading = useProgressStore((s) => s.isLoading);
const scrollRef = useRef<HTMLDivElement>(null);
// Elapsed time counter while loading
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
if (!isLoading) {
setElapsed(0);
return;
}
const interval = setInterval(() => setElapsed((e) => e + 1), 1000);
return () => clearInterval(interval);
}, [isLoading]);
// Auto-scroll to bottom when messages change
useEffect(() => {
scrollRef.current?.scrollIntoView({ behavior: "smooth" });
@ -219,7 +231,7 @@ export default function ChatPage() {
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="mb-4 flex items-center justify-between">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-3">
<MessageSquareText className="h-6 w-6 text-brand-400" />
<h1 className="text-2xl font-bold text-fg">Chat</h1>
@ -228,7 +240,7 @@ export default function ChatPage() {
</span>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
{/* Mode selector */}
<div className="flex rounded-lg border border-border bg-surface-100 p-0.5">
{MODES.map((mode) => (
@ -279,7 +291,14 @@ export default function ChatPage() {
{isLoading && (
<div className="flex items-center gap-2 pb-2 text-xs text-fg-subtle">
<Loader2 className="h-3 w-3 animate-spin" />
<span>Processing...</span>
<span>Processing... {elapsed}s</span>
<button
onClick={cancelRequest}
className="flex items-center gap-1 rounded-lg px-3 py-1 text-xs text-red-400 hover:bg-surface-200"
>
<X className="h-3 w-3" />
Cancel
</button>
</div>
)}

View file

@ -43,6 +43,7 @@ function StartFlowDialog({
const [paramsJson, setParamsJson] = useState("{}");
const [submitting, setSubmitting] = useState(false);
const [paramsError, setParamsError] = useState<string | null>(null);
const [submitted, setSubmitted] = useState(false);
// Fetch blueprints when dialog opens
useEffect(() => {
@ -70,9 +71,13 @@ function StartFlowDialog({
setParamsJson("{}");
setParamsError(null);
setSubmitting(false);
setSubmitted(false);
};
const handleSubmit = async () => {
setSubmitted(true);
if (!isValid) return;
let params: Record<string, unknown> = {};
try {
params = JSON.parse(paramsJson);
@ -140,6 +145,9 @@ function StartFlowDialog({
placeholder="my-flow-id"
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
{submitted && !id.trim() && (
<p className="mt-1 text-xs text-red-400">Flow ID is required</p>
)}
</div>
{/* Blueprint name */}
@ -167,6 +175,9 @@ function StartFlowDialog({
))}
</select>
)}
{submitted && !blueprint && (
<p className="mt-1 text-xs text-red-400">Blueprint is required</p>
)}
</div>
{/* Description */}
@ -181,6 +192,9 @@ function StartFlowDialog({
placeholder="Human-readable description"
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
{submitted && !description.trim() && (
<p className="mt-1 text-xs text-red-400">Description is required</p>
)}
</div>
{/* Parameters (JSON) */}

View file

@ -83,7 +83,13 @@ export default function KnowledgeCoresPage() {
try {
setLoading(true);
setError(null);
const ids = await socket.knowledge().getKnowledgeCores();
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("Request timed out")), 15000),
);
const ids = await Promise.race([
socket.knowledge().getKnowledgeCores(),
timeoutPromise,
]);
setCores(Array.isArray(ids) ? ids : []);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
@ -144,13 +150,15 @@ export default function KnowledgeCoresPage() {
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<div className="mb-6 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-3">
<BrainCircuit className="h-6 w-6 text-brand-400" />
<h1 className="text-2xl font-bold text-fg">Knowledge Cores</h1>
<span className="ml-2 rounded bg-surface-200 px-2 py-0.5 text-xs text-fg-subtle">
{cores.length} core{cores.length !== 1 ? "s" : ""}
</span>
{!loading && (
<span className="ml-2 rounded bg-surface-200 px-2 py-0.5 text-xs text-fg-subtle">
{cores.length} core{cores.length !== 1 ? "s" : ""}
</span>
)}
</div>
<button

View file

@ -52,7 +52,7 @@ export default function PromptsPage() {
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<div className="mb-6 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-3">
<MessageCircleCode className="h-6 w-6 text-brand-400" />
<h1 className="text-2xl font-bold text-fg">Prompts</h1>

View file

@ -300,6 +300,9 @@ export default function SettingsPage() {
<p className="text-xs text-fg-subtle">
Toggle between dark and light mode.
</p>
<p className="text-xs text-fg-subtle">
Currently using {isDark ? "dark" : "light"} mode.
</p>
</div>
<button
onClick={toggleTheme}

View file

@ -67,7 +67,7 @@ export default function TokenCostPage() {
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<div className="mb-6 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-3">
<Coins className="h-6 w-6 text-brand-400" />
<h1 className="text-2xl font-bold text-fg">Token Cost</h1>

View file

@ -1,5 +1,6 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
@ -112,14 +113,20 @@ export function useConnectionState(): ConnectionState {
hasApiKey: false,
});
const subscribe = (onStoreChange: () => void) => {
return api.onConnectionStateChange((next) => {
stateRef.current = next;
onStoreChange();
});
};
// subscribe must be stable across renders to prevent useSyncExternalStore
// from re-subscribing on every render (which would cause an infinite loop
// because onConnectionStateChange immediately calls the listener).
const subscribe = useCallback(
(onStoreChange: () => void) => {
return api.onConnectionStateChange((next) => {
stateRef.current = next;
onStoreChange();
});
},
[api],
);
const getSnapshot = () => stateRef.current;
const getSnapshot = useCallback(() => stateRef.current, []);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}