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

@ -3,5 +3,6 @@ node_modules
deploy
**/dist
**/.turbo
**/*.tsbuildinfo
*.log
.env*

View file

@ -21,7 +21,7 @@ RUN pnpm install --frozen-lockfile
# Copy source and build
COPY packages/ packages/
RUN pnpm build
RUN pnpm build --filter=@trustgraph/base --filter=@trustgraph/client --filter=@trustgraph/flow
# ---------------------------------------------------------------------------
# Stage 2: Runtime

View file

@ -66,7 +66,7 @@ services:
networks:
- trustgraph
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:6333/healthz"]
test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/localhost/6333'"]
interval: 10s
timeout: 5s
retries: 3
@ -179,6 +179,7 @@ services:
- GF_AUTH_DISABLE_LOGIN_FORM=false
- GF_USERS_DEFAULT_THEME=dark
- GF_EXPLORE_ENABLED=true
- GF_USERS_VIEWERS_CAN_EDIT=true
- GF_FEATURE_TOGGLES_ENABLE=traceqlEditor tempoSearch tempoServiceGraph
depends_on:
prometheus:
@ -323,6 +324,18 @@ services:
- trustgraph
restart: unless-stopped
knowledge-cores:
image: trustgraph-ts:local
command: ["node", "entrypoints/cores.mjs"]
environment:
- NATS_URL=nats://nats:4222
depends_on:
nats:
condition: service_healthy
networks:
- trustgraph
restart: unless-stopped
# ---------------------------------------------------------------------------
# Document Processing Pipeline
# ---------------------------------------------------------------------------

View file

@ -32,7 +32,7 @@
"id": 1,
"targets": [
{
"expr": "up",
"expr": "up{job=~\"prometheus|otel-collector|gateway\"}",
"legendFormat": "{{job}}",
"refId": "A"
}

View file

@ -28,7 +28,7 @@
"type": "prometheus",
"uid": "tg-prometheus"
},
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 0 },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
"id": 1,
"targets": [
{
@ -92,7 +92,7 @@
"type": "prometheus",
"uid": "tg-prometheus"
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"id": 2,
"targets": [
{
@ -151,7 +151,7 @@
"type": "prometheus",
"uid": "tg-prometheus"
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
"id": 3,
"targets": [
{
@ -210,7 +210,7 @@
"type": "prometheus",
"uid": "tg-prometheus"
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
"id": 4,
"targets": [
{
@ -269,7 +269,7 @@
"type": "prometheus",
"uid": "tg-prometheus"
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 },
"id": 5,
"targets": [
{
@ -328,7 +328,7 @@
"type": "prometheus",
"uid": "tg-prometheus"
},
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 24 },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 },
"id": 6,
"targets": [
{

View file

@ -13,13 +13,10 @@ scrape_configs:
- targets:
- "prometheus:9090"
# NATS monitoring
- job_name: "nats"
scrape_interval: 15s
metrics_path: "/varz"
static_configs:
- targets:
- "nats:8222"
# NATS monitoring (uses nats-prometheus-exporter format)
# NATS exposes JSON at /varz, not Prometheus format.
# To get proper Prometheus metrics, deploy nats-exporter sidecar.
# For now, we rely on NATS healthcheck and JetStream monitoring via /jsz.
# OpenTelemetry Collector (exposes Prometheus metrics from OTLP pipeline)
- job_name: "otel-collector"
@ -28,9 +25,10 @@ scrape_configs:
- targets:
- "otel-collector:8889"
# TrustGraph gateway (enabled when gateway container is running)
# TrustGraph gateway metrics (prom-client)
- job_name: "gateway"
scrape_interval: 5s
scrape_interval: 15s
metrics_path: "/api/v1/metrics"
static_configs:
- targets:
- "gateway:8000"
- "gateway:8088"

6
ts/entrypoints/cores.mjs Normal file
View file

@ -0,0 +1,6 @@
import("../packages/flow/dist/cores/service.js")
.then((m) => m.run())
.catch((err) => {
console.error(err);
process.exit(1);
});

View file

@ -126,12 +126,13 @@ export interface DocumentEmbeddingsResponse {
}
// Config
export type ConfigOperation = "get" | "list" | "delete" | "put" | "config";
export type ConfigOperation = "get" | "list" | "delete" | "put" | "config" | "getvalues";
export interface ConfigRequest {
operation: ConfigOperation;
keys?: string[];
values?: Record<string, unknown>;
type?: string;
}
export interface ConfigResponse {

View file

@ -1174,7 +1174,8 @@ export class FlowsApi {
string,
Record<string, Record<string, string>>
>;
return JSON.parse(config.config.prompt["template-index"]);
const raw = config.config?.prompt?.["template-index"];
return raw ? JSON.parse(raw) : [];
});
}
@ -1187,7 +1188,8 @@ export class FlowsApi {
string,
Record<string, Record<string, string>>
>;
return JSON.parse(config.config.prompt[`template.${id}`]);
const raw = config.config?.prompt?.[`template.${id}`];
return raw ? JSON.parse(raw) : null;
});
}
@ -1200,7 +1202,8 @@ export class FlowsApi {
string,
Record<string, Record<string, string>>
>;
return JSON.parse(config.config.prompt.system);
const raw = config.config?.prompt?.system;
return raw ? JSON.parse(raw) : "";
});
}
@ -1546,7 +1549,10 @@ export class FlowApi {
60000,
undefined,
this.flowId,
);
).catch((err) => {
const errorMessage = err instanceof Error ? err.message : err?.toString() || "Unknown error";
onError(`Graph RAG request failed: ${errorMessage}`);
});
}
/**
@ -1617,7 +1623,10 @@ export class FlowApi {
60000,
undefined,
this.flowId,
);
).catch((err) => {
const errorMessage = err instanceof Error ? err.message : err?.toString() || "Unknown error";
onError(`Document RAG request failed: ${errorMessage}`);
});
}
/**

View file

@ -129,6 +129,9 @@ export class ConfigService extends AsyncProcessor {
case "config":
return this.handleConfigDump();
case "getvalues":
return this.handleGetValues(request);
default:
throw new Error(`Unknown config operation: ${op as string}`);
}
@ -237,6 +240,22 @@ export class ConfigService extends AsyncProcessor {
};
}
private handleGetValues(request: ConfigRequest): ConfigResponse {
const type = request.type ?? "";
const values: { key: string; value: unknown }[] = [];
for (const [namespace, subMap] of this.store) {
if (!type || namespace === type || namespace.startsWith(`${type}.`) || namespace.startsWith(`${type}/`)) {
for (const [k, v] of subMap) {
values.push({ key: `${namespace}.${k}`, value: v });
}
}
}
return { version: this.version, values: values as unknown as Record<string, unknown> };
}
private handleConfigDump(): ConfigResponse {
const config: Record<string, unknown> = {};

View file

@ -47,7 +47,12 @@ export async function createGateway(config: GatewayConfig) {
const body = request.body as Record<string, unknown>;
try {
const result = await dispatcher.dispatchGlobalService(kind, body);
const result = await dispatcher.dispatchGlobalService(kind, body) as Record<string, unknown>;
const err = result?.error as { type?: string; message?: string } | undefined;
if (err) {
const statusCode = err.type === "not-found" ? 404 : 400;
return reply.code(statusCode).send(result);
}
return result;
} catch (err) {
reply.code(500).send({ error: { type: "internal", message: String(err) } });
@ -62,7 +67,12 @@ export async function createGateway(config: GatewayConfig) {
const body = request.body as Record<string, unknown>;
try {
const result = await dispatcher.dispatchFlowService(flow, kind, body);
const result = await dispatcher.dispatchFlowService(flow, kind, body) as Record<string, unknown>;
const err = result?.error as { type?: string; message?: string } | undefined;
if (err) {
const statusCode = err.type === "not-found" ? 404 : 400;
return reply.code(statusCode).send(result);
}
return result;
} catch (err) {
reply.code(500).send({ error: { type: "internal", message: String(err) } });

View file

@ -25,6 +25,9 @@ function termToValue(term: Term | undefined): string | null {
}
function createTerm(value: string): Term {
if (!value) {
return { type: "LITERAL", value: "" };
}
if (value.startsWith("http://") || value.startsWith("https://")) {
return { type: "IRI", iri: value };
}
@ -90,11 +93,14 @@ export class FalkorDBTriplesQuery {
await this.matchAll(rawTriples, limit);
}
return rawTriples.slice(0, limit).map(([s, p, o]) => ({
s: createTerm(s),
p: createTerm(p),
o: createTerm(o),
}));
return rawTriples
.filter(([s, p, o]) => s != null && p != null && o != null)
.slice(0, limit)
.map(([s, p, o]) => ({
s: createTerm(s),
p: createTerm(p),
o: createTerm(o),
}));
}
private async matchPattern(

View file

@ -16,7 +16,17 @@ server {
proxy_set_header X-Real-IP $remote_addr;
}
# WebSocket proxy
# WebSocket proxy (client connects to /api/socket, gateway listens on /api/v1/socket)
location /api/socket {
proxy_pass http://gateway:8088/api/v1/socket;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400;
}
# WebSocket proxy (direct v1 path)
location /api/v1/socket {
proxy_pass http://gateway:8088;
proxy_http_version 1.1;

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);
}