@pranshulsoni/flowwatch 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +442 -0
- package/dist/ai/groqInsightService.d.ts +39 -0
- package/dist/ai/groqInsightService.js +230 -0
- package/dist/createFlowwatch.d.ts +17 -0
- package/dist/createFlowwatch.js +90 -0
- package/dist/dashboard/routes/dashboardResponse.d.ts +204 -0
- package/dist/dashboard/routes/dashboardResponse.js +248 -0
- package/dist/dashboard/routes/router.d.ts +13 -0
- package/dist/dashboard/routes/router.js +708 -0
- package/dist/dashboard/static/dashboard.html +6061 -0
- package/dist/engine/background/queues/workflowQueue.d.ts +6 -0
- package/dist/engine/background/queues/workflowQueue.js +14 -0
- package/dist/engine/background/workers/workflowWorker.d.ts +15 -0
- package/dist/engine/background/workers/workflowWorker.js +98 -0
- package/dist/engine/errors/errorEngine.d.ts +27 -0
- package/dist/engine/errors/errorEngine.js +115 -0
- package/dist/engine/flags/evaluateFlag.d.ts +3 -0
- package/dist/engine/flags/evaluateFlag.js +50 -0
- package/dist/engine/flags/flagEngine.d.ts +9 -0
- package/dist/engine/flags/flagEngine.js +52 -0
- package/dist/engine/flags/hashRollout.d.ts +1 -0
- package/dist/engine/flags/hashRollout.js +9 -0
- package/dist/engine/flags/types.d.ts +7 -0
- package/dist/engine/flags/types.js +1 -0
- package/dist/engine/trace/traceEngine.d.ts +26 -0
- package/dist/engine/trace/traceEngine.js +76 -0
- package/dist/engine/workflows/types.d.ts +28 -0
- package/dist/engine/workflows/types.js +1 -0
- package/dist/engine/workflows/workflowEngine.d.ts +15 -0
- package/dist/engine/workflows/workflowEngine.js +112 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +3 -0
- package/dist/persistence/cache/redisClient.d.ts +2 -0
- package/dist/persistence/cache/redisClient.js +4 -0
- package/dist/persistence/db/postgres.d.ts +3 -0
- package/dist/persistence/db/postgres.js +4 -0
- package/dist/persistence/migrations/migrationRunner.d.ts +3 -0
- package/dist/persistence/migrations/migrationRunner.js +46 -0
- package/dist/persistence/migrations/migrations.d.ts +5 -0
- package/dist/persistence/migrations/migrations.js +191 -0
- package/dist/persistence/repositories/errors/errorRepository.d.ts +38 -0
- package/dist/persistence/repositories/errors/errorRepository.js +63 -0
- package/dist/persistence/repositories/flags/flagRepository.d.ts +72 -0
- package/dist/persistence/repositories/flags/flagRepository.js +245 -0
- package/dist/persistence/repositories/traces/traceRepository.d.ts +64 -0
- package/dist/persistence/repositories/traces/traceRepository.js +110 -0
- package/dist/persistence/repositories/workflows/workflowRepository.d.ts +93 -0
- package/dist/persistence/repositories/workflows/workflowRepository.js +260 -0
- package/dist/persistence/transaction.d.ts +2 -0
- package/dist/persistence/transaction.js +16 -0
- package/dist/runtime/config/normalizeConfig.d.ts +2 -0
- package/dist/runtime/config/normalizeConfig.js +46 -0
- package/dist/runtime/config/validationConfig.d.ts +2 -0
- package/dist/runtime/config/validationConfig.js +119 -0
- package/dist/runtime/health/healthService.d.ts +30 -0
- package/dist/runtime/health/healthService.js +54 -0
- package/dist/runtime/tracing/traceContext.d.ts +12 -0
- package/dist/runtime/tracing/traceContext.js +28 -0
- package/dist/runtime/tracing/tracingMiddleware.d.ts +3 -0
- package/dist/runtime/tracing/tracingMiddleware.js +46 -0
- package/dist/search/elasticsearch/client.d.ts +2 -0
- package/dist/search/elasticsearch/client.js +4 -0
- package/dist/search/elasticsearch/indexSetup.d.ts +3 -0
- package/dist/search/elasticsearch/indexSetup.js +43 -0
- package/dist/search/elasticsearch/indexer.d.ts +9 -0
- package/dist/search/elasticsearch/indexer.js +86 -0
- package/dist/search/elasticsearch/mappingChecker.d.ts +2 -0
- package/dist/search/elasticsearch/mappingChecker.js +28 -0
- package/dist/types/index.d.ts +48 -0
- package/dist/types/index.js +1 -0
- package/dist/utils/flowwatchEnvStore.d.ts +27 -0
- package/dist/utils/flowwatchEnvStore.js +145 -0
- package/package.json +63 -0
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import express, { json, Router } from "express";
|
|
4
|
+
import { checkElasticsearchHealth, checkPostgresHealth, checkRedisHealth } from "../../runtime/health/healthService.js";
|
|
5
|
+
import { createFlag, createFlagRule, deleteFlag, deleteFlagRule, getFlagByKey, listFlagAuditLogs, listFlagsWithRuleCounts, listFlagRules, updateFlag, updateFlagRule, } from "../../persistence/repositories/flags/flagRepository.js";
|
|
6
|
+
import { createRequestTracingMiddleware } from "../../runtime/tracing/tracingMiddleware.js";
|
|
7
|
+
import { getErrorById, getErrorsByTrace, listErrors } from "../../persistence/repositories/errors/errorRepository.js";
|
|
8
|
+
import { getRequestTrace, getTraceSpans, listRequestTraces } from "../../persistence/repositories/traces/traceRepository.js";
|
|
9
|
+
import { getLatestWorkflowDefinitionByName, getWorkflowExecution, getWorkflowExecutionSteps, listWorkflowDefinitions, listWorkflowExecutions, listWorkflowExecutionsByWorkflowName, listWorkflowStepExecutionsByExecutionIds, listWorkflowStepsByWorkflowIds, } from "../../persistence/repositories/workflows/workflowRepository.js";
|
|
10
|
+
import { latestByWorkflow, serializeAuditLog, serializeError, serializeExecution, serializeFlag, serializeRule, serializeSettings, serializeTrace, serializeWorkflowSummary, } from "./dashboardResponse.js";
|
|
11
|
+
import { generateGroqInsight, listGroqModels, askGroqAi } from "../../ai/groqInsightService.js";
|
|
12
|
+
import { saveFlowwatchEnv, isGroqApiKeyConfigured } from "../../utils/flowwatchEnvStore.js";
|
|
13
|
+
import { captureError } from "../../engine/errors/errorEngine.js";
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
/** Capture an AI error into the Flowwatch error store and surface a meaningful response */
|
|
16
|
+
async function handleAiError(engineOptions, res, error, route) {
|
|
17
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
18
|
+
const msg = err.message;
|
|
19
|
+
// Store it so it shows up in the dashboard Errors tab
|
|
20
|
+
try {
|
|
21
|
+
await captureError(engineOptions, err, { source: "dashboard_api", category: "server", level: "error", statusCode: 502,
|
|
22
|
+
metadata: { route } });
|
|
23
|
+
}
|
|
24
|
+
catch { /* never block the response */ }
|
|
25
|
+
console.error(`[Flowwatch] AI route error (${route}):`, msg);
|
|
26
|
+
if (msg.includes("not configured") || msg.includes("GROQ_API_KEY")) {
|
|
27
|
+
res.status(428).json({ error: { code: "groq_api_key_missing",
|
|
28
|
+
message: "Groq API key not configured. Add it in Settings → AI Configuration." } });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (msg.includes("401") || msg.includes("403") || msg.toLowerCase().includes("invalid api key")) {
|
|
32
|
+
res.status(502).json({ error: { code: "groq_auth_error",
|
|
33
|
+
message: `Groq rejected the API key: ${msg.slice(0, 300)}` } });
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (msg.includes("413") || msg.toLowerCase().includes("too large") || msg.toLowerCase().includes("rate limit")) {
|
|
37
|
+
res.status(502).json({ error: { code: "groq_rate_limit",
|
|
38
|
+
message: "Groq rate limit or token limit exceeded. Try again shortly." } });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
res.status(502).json({ error: { code: "groq_request_failed",
|
|
42
|
+
message: `AI request failed: ${msg.slice(0, 300)}` } });
|
|
43
|
+
}
|
|
44
|
+
async function invalidateFlagCache(redisClient, flagKey) {
|
|
45
|
+
try {
|
|
46
|
+
await redisClient.del(`flowwatch:flags:${flagKey}`);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function validate(schema) {
|
|
52
|
+
return (req, res, next) => {
|
|
53
|
+
const result = schema.safeParse(req.body);
|
|
54
|
+
if (!result.success) {
|
|
55
|
+
res.status(400).json({
|
|
56
|
+
error: {
|
|
57
|
+
code: "validation_error",
|
|
58
|
+
message: result.error.issues.map((e) => `${e.path.join(".")}: ${e.message}`).join("; "),
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
req.body = result.data;
|
|
64
|
+
next();
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function validateParams(schema) {
|
|
68
|
+
return (req, res, next) => {
|
|
69
|
+
const result = schema.safeParse(req.params);
|
|
70
|
+
if (!result.success) {
|
|
71
|
+
res.status(400).json({
|
|
72
|
+
error: {
|
|
73
|
+
code: "validation_error",
|
|
74
|
+
message: "Invalid route parameter",
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
req.params = result.data;
|
|
80
|
+
next();
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function validateQuery(schema) {
|
|
84
|
+
return (req, res, next) => {
|
|
85
|
+
const result = schema.safeParse(req.query);
|
|
86
|
+
if (!result.success) {
|
|
87
|
+
res.status(400).json({
|
|
88
|
+
error: {
|
|
89
|
+
code: "validation_error",
|
|
90
|
+
message: "Invalid query parameter",
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
req.validatedQuery = result.data; // req.query is read-only in Express 5
|
|
96
|
+
next();
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function asyncRoute(fn) {
|
|
100
|
+
return async (req, res) => {
|
|
101
|
+
try {
|
|
102
|
+
await fn(req, res);
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
await dashboardError(res);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
async function dashboardError(res, error) {
|
|
110
|
+
if (error)
|
|
111
|
+
console.error("[Flowwatch dashboard] API error", error);
|
|
112
|
+
res.status(500).json({
|
|
113
|
+
error: {
|
|
114
|
+
code: "dashboard_api_error",
|
|
115
|
+
message: "An internal error occurred",
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Surfaces a meaningful error when an AI provider call fails.
|
|
121
|
+
* Distinguishes between: API key not configured, token limit exceeded, bad key, other.
|
|
122
|
+
*/
|
|
123
|
+
async function aiProviderError(res, error) {
|
|
124
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
125
|
+
console.error("[Flowwatch dashboard] AI provider error:", msg);
|
|
126
|
+
// Key not loaded into store yet
|
|
127
|
+
if (msg.includes("not configured")) {
|
|
128
|
+
res.status(428).json({
|
|
129
|
+
error: {
|
|
130
|
+
code: "groq_api_key_missing",
|
|
131
|
+
message: "Groq API key not configured. Add it in Settings → AI Configuration.",
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
// Token limit exceeded
|
|
137
|
+
if (msg.includes("413") || msg.toLowerCase().includes("token") || msg.toLowerCase().includes("too large")) {
|
|
138
|
+
res.status(502).json({
|
|
139
|
+
error: {
|
|
140
|
+
code: "groq_token_limit",
|
|
141
|
+
message: "AI request exceeded the model's token limit. Try selecting a model with a higher context limit.",
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
// Bad API key / auth failure
|
|
147
|
+
if (msg.includes("401") || msg.includes("403")) {
|
|
148
|
+
res.status(502).json({
|
|
149
|
+
error: {
|
|
150
|
+
code: "groq_auth_error",
|
|
151
|
+
message: "Groq rejected the API key. Re-enter your key in Settings → AI Configuration.",
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
res.status(502).json({
|
|
157
|
+
error: {
|
|
158
|
+
code: "groq_request_failed",
|
|
159
|
+
message: `Groq request failed: ${msg.slice(0, 200)}`,
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
async function buildAiInsightContext(options) {
|
|
164
|
+
const { config, postgresPool, redisClient, elasticsearchClient } = options;
|
|
165
|
+
const [postgres, redis, elasticsearch, flags, workflows, executions, traces, errors,] = await Promise.all([
|
|
166
|
+
checkPostgresHealth(postgresPool).catch(() => ({ status: "error", latencyMs: -1 })),
|
|
167
|
+
checkRedisHealth(redisClient).catch(() => ({ status: "error", latencyMs: -1 })),
|
|
168
|
+
checkElasticsearchHealth(elasticsearchClient).catch(() => ({ status: "error", latencyMs: -1 })),
|
|
169
|
+
listFlagsWithRuleCounts(postgresPool).catch(() => []),
|
|
170
|
+
listWorkflowDefinitions(postgresPool).catch(() => []),
|
|
171
|
+
listWorkflowExecutions(postgresPool, 10).catch(() => []),
|
|
172
|
+
listRequestTraces(postgresPool, 8).catch(() => []),
|
|
173
|
+
listErrors(postgresPool, 10).catch(() => []),
|
|
174
|
+
]);
|
|
175
|
+
const executionSteps = await listWorkflowStepExecutionsByExecutionIds(postgresPool, executions.map((e) => e.id)).catch(() => new Map());
|
|
176
|
+
return {
|
|
177
|
+
serviceName: config.runtime.serviceName,
|
|
178
|
+
environment: config.runtime.environment,
|
|
179
|
+
generatedAt: new Date().toISOString(),
|
|
180
|
+
// Workflows — name + version only (no IDs)
|
|
181
|
+
workflows: workflows.slice(0, 6).map((w) => ({
|
|
182
|
+
name: w.name,
|
|
183
|
+
version: w.version,
|
|
184
|
+
})),
|
|
185
|
+
// Executions — status + step summary only (no input/output/IDs)
|
|
186
|
+
executions: executions.slice(0, 8).map((execution) => {
|
|
187
|
+
const steps = (executionSteps.get(execution.id) || []).slice(0, 5);
|
|
188
|
+
return {
|
|
189
|
+
workflow: execution.workflow_name,
|
|
190
|
+
status: execution.status,
|
|
191
|
+
failedStep: steps.find((s) => s.status === "failed")?.step_name ?? null,
|
|
192
|
+
steps: steps.map((s) => ({ name: s.step_name, status: s.status })),
|
|
193
|
+
};
|
|
194
|
+
}),
|
|
195
|
+
// Errors — no stack traces, message capped at 120 chars
|
|
196
|
+
errors: errors.slice(0, 8).map((error) => ({
|
|
197
|
+
name: error.name,
|
|
198
|
+
message: String(error.message || "").slice(0, 120),
|
|
199
|
+
category: error.category,
|
|
200
|
+
level: error.level,
|
|
201
|
+
source: error.source,
|
|
202
|
+
})),
|
|
203
|
+
// Traces — summary only, no spans, path capped at 80 chars
|
|
204
|
+
traces: traces.slice(0, 5).map((trace) => ({
|
|
205
|
+
method: trace.method,
|
|
206
|
+
path: String(trace.path || "").slice(0, 80),
|
|
207
|
+
status: trace.status_code,
|
|
208
|
+
durationMs: trace.duration_ms,
|
|
209
|
+
})),
|
|
210
|
+
// Flags — key + state + rollout only
|
|
211
|
+
flags: flags.slice(0, 6).map((flag) => ({
|
|
212
|
+
key: flag.key,
|
|
213
|
+
enabled: flag.enabled,
|
|
214
|
+
rollout: flag.rollout_percentage,
|
|
215
|
+
})),
|
|
216
|
+
// Health — status + latency only
|
|
217
|
+
health: [
|
|
218
|
+
{ name: "Postgres", status: postgres.status, latency: postgres.latencyMs },
|
|
219
|
+
{ name: "Redis", status: redis.status, latency: redis.latencyMs },
|
|
220
|
+
{ name: "Elasticsearch", status: elasticsearch.status, latency: elasticsearch.latencyMs },
|
|
221
|
+
{ name: "Worker", status: config.worker.enabled ? "ok" : "degraded" },
|
|
222
|
+
],
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
export function createDashboardRouter(options) {
|
|
226
|
+
const router = Router();
|
|
227
|
+
const { config, postgresPool, redisClient, elasticsearchClient } = options;
|
|
228
|
+
router.use(json({ limit: "100kb" }));
|
|
229
|
+
router.use(createRequestTracingMiddleware(postgresPool));
|
|
230
|
+
const flagKeyParamSchema = z.object({
|
|
231
|
+
key: z.string().min(1).max(128).regex(/^[a-zA-Z0-9._:-]+$/),
|
|
232
|
+
});
|
|
233
|
+
const workflowNameParamSchema = z.object({
|
|
234
|
+
name: z.string().min(1).max(160),
|
|
235
|
+
});
|
|
236
|
+
const idParamSchema = (name) => z.object({
|
|
237
|
+
[name]: z.string().min(1).max(160).regex(/^[a-zA-Z0-9._:-]+$/),
|
|
238
|
+
});
|
|
239
|
+
const optionalWorkflowQuerySchema = z.object({
|
|
240
|
+
workflow: z.string().min(1).max(160).optional(),
|
|
241
|
+
});
|
|
242
|
+
const optionalTraceQuerySchema = z.object({
|
|
243
|
+
traceId: z.string().min(1).max(160).regex(/^[a-zA-Z0-9._:-]+$/).optional(),
|
|
244
|
+
});
|
|
245
|
+
const optionalModelQuerySchema = z.object({
|
|
246
|
+
model: z.string().min(1).max(128).regex(/^[a-zA-Z0-9._:/-]+$/).optional(),
|
|
247
|
+
});
|
|
248
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
249
|
+
const __dirname = dirname(__filename);
|
|
250
|
+
const staticPath = join(__dirname, "../static");
|
|
251
|
+
router.use(express.static(staticPath));
|
|
252
|
+
router.get("/", (req, res) => {
|
|
253
|
+
res.sendFile(join(staticPath, "dashboard.html"));
|
|
254
|
+
});
|
|
255
|
+
router.get("/api/health", async (req, res) => {
|
|
256
|
+
const postgres = await checkPostgresHealth(postgresPool);
|
|
257
|
+
const redis = await checkRedisHealth(redisClient);
|
|
258
|
+
const elasticsearch = await checkElasticsearchHealth(elasticsearchClient);
|
|
259
|
+
res.json({
|
|
260
|
+
status: postgres.status === "ok" && redis.status === "ok" && elasticsearch.status === "ok" ? "ok" : "degraded",
|
|
261
|
+
serviceName: config.runtime.serviceName,
|
|
262
|
+
checks: {
|
|
263
|
+
postgres,
|
|
264
|
+
redis,
|
|
265
|
+
elasticsearch,
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
router.get("/api/dashboard-data", async (req, res) => {
|
|
270
|
+
try {
|
|
271
|
+
const [postgres, redis, elasticsearch, flagsResult, workflowsResult, executionsResult, tracesResult, errors,] = await Promise.all([
|
|
272
|
+
checkPostgresHealth(postgresPool),
|
|
273
|
+
checkRedisHealth(redisClient),
|
|
274
|
+
checkElasticsearchHealth(elasticsearchClient),
|
|
275
|
+
listFlagsWithRuleCounts(postgresPool),
|
|
276
|
+
listWorkflowDefinitions(postgresPool),
|
|
277
|
+
listWorkflowExecutions(postgresPool, 50),
|
|
278
|
+
listRequestTraces(postgresPool, 50),
|
|
279
|
+
listErrors(postgresPool, 50),
|
|
280
|
+
]);
|
|
281
|
+
const executionSteps = await listWorkflowStepExecutionsByExecutionIds(postgresPool, executionsResult.map((execution) => execution.id));
|
|
282
|
+
const workflowSteps = await listWorkflowStepsByWorkflowIds(postgresPool, workflowsResult.map((workflow) => workflow.id));
|
|
283
|
+
const latestExecutions = latestByWorkflow(executionsResult);
|
|
284
|
+
const traceSpans = new Map();
|
|
285
|
+
await Promise.all(tracesResult.map(async (trace) => {
|
|
286
|
+
traceSpans.set(trace.id, await getTraceSpans(postgresPool, trace.id));
|
|
287
|
+
}));
|
|
288
|
+
res.json({
|
|
289
|
+
serviceName: config.runtime.serviceName,
|
|
290
|
+
environment: config.runtime.environment,
|
|
291
|
+
settings: serializeSettings(config),
|
|
292
|
+
workflows: workflowsResult.map((workflow) => {
|
|
293
|
+
const latestExecution = latestExecutions.get(workflow.name);
|
|
294
|
+
const steps = executionSteps.get(latestExecution?.id) || [];
|
|
295
|
+
const definitionSteps = workflowSteps.get(workflow.id) || [];
|
|
296
|
+
return serializeWorkflowSummary(workflow, executionsResult, definitionSteps, latestExecution, steps);
|
|
297
|
+
}),
|
|
298
|
+
executions: executionsResult.map((execution) => serializeExecution(execution, executionSteps.get(execution.id) || [])),
|
|
299
|
+
flags: flagsResult.map((flag) => serializeFlag(flag, flag.rule_count)),
|
|
300
|
+
errors: errors.map(serializeError),
|
|
301
|
+
traces: tracesResult.map((trace) => serializeTrace(trace, traceSpans.get(trace.id) || [])),
|
|
302
|
+
health: [
|
|
303
|
+
{ name: "Postgres", status: postgres.status, latency: postgres.latencyMs, description: "Primary persistence for workflows, flags, traces, and errors." },
|
|
304
|
+
{ name: "Redis", status: redis.status, latency: redis.latencyMs, description: "BullMQ queue transport and feature flag cache." },
|
|
305
|
+
{ name: "Elasticsearch", status: elasticsearch.status, latency: elasticsearch.latencyMs, description: "Search index for errors and trace spans." },
|
|
306
|
+
{ name: "BullMQ Worker", status: config.worker.enabled ? "ok" : "degraded", latency: 0, description: "Background workflow execution worker." },
|
|
307
|
+
],
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
catch (error) {
|
|
311
|
+
await dashboardError(res);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
router.get("/api/settings", async (req, res) => {
|
|
315
|
+
res.json({
|
|
316
|
+
settings: serializeSettings(config),
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
router.get("/api/ai-models", async (req, res) => {
|
|
320
|
+
try {
|
|
321
|
+
if (!isGroqApiKeyConfigured()) {
|
|
322
|
+
res.status(428).json({
|
|
323
|
+
error: {
|
|
324
|
+
code: "groq_api_key_missing",
|
|
325
|
+
message: "Add GROQ_API_KEY before syncing Groq models.",
|
|
326
|
+
},
|
|
327
|
+
models: [],
|
|
328
|
+
});
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
res.json({
|
|
332
|
+
models: await listGroqModels(),
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
await dashboardError(res);
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
router.get("/api/ai-insights", validateQuery(optionalModelQuerySchema), async (req, res) => {
|
|
340
|
+
try {
|
|
341
|
+
if (!isGroqApiKeyConfigured()) {
|
|
342
|
+
res.status(428).json({
|
|
343
|
+
error: { code: "groq_api_key_missing",
|
|
344
|
+
message: "Add your Groq API key in Settings → AI Configuration." },
|
|
345
|
+
modelConfigured: false,
|
|
346
|
+
});
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
const context = await buildAiInsightContext(options);
|
|
350
|
+
const model = typeof req.validatedQuery?.model === "string" ? req.validatedQuery.model : undefined;
|
|
351
|
+
const insight = await generateGroqInsight(context, model);
|
|
352
|
+
res.json({ insight, modelConfigured: true });
|
|
353
|
+
}
|
|
354
|
+
catch (error) {
|
|
355
|
+
await handleAiError({ pool: postgresPool, elasticsearchClient }, res, error, "GET /api/ai-insights");
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
router.get("/api/flags", async (req, res) => {
|
|
359
|
+
const flags = await listFlagsWithRuleCounts(postgresPool);
|
|
360
|
+
res.json({ flags: flags.map((flag) => serializeFlag(flag, flag.rule_count)) });
|
|
361
|
+
});
|
|
362
|
+
const flagCreateSchema = z.object({
|
|
363
|
+
key: z.string().min(1).max(128),
|
|
364
|
+
description: z.string().max(512).optional(),
|
|
365
|
+
enabled: z.boolean().optional(),
|
|
366
|
+
rolloutPercentage: z.number().min(0).max(100).optional(),
|
|
367
|
+
changedBy: z.string().max(128).optional(),
|
|
368
|
+
}).strict();
|
|
369
|
+
const flagUpdateSchema = z.object({
|
|
370
|
+
description: z.string().max(512).optional(),
|
|
371
|
+
enabled: z.boolean().optional(),
|
|
372
|
+
rolloutPercentage: z.number().min(0).max(100).optional(),
|
|
373
|
+
changedBy: z.string().max(128).optional(),
|
|
374
|
+
}).strict();
|
|
375
|
+
const flagRuleSchema = z.object({
|
|
376
|
+
attribute: z.string().min(1).max(128),
|
|
377
|
+
operator: z.enum(["equals", "not_equals", "in", "not_in", "contains", "starts_with", "ends_with"]),
|
|
378
|
+
value: z.string().min(1).max(512),
|
|
379
|
+
enabled: z.boolean().optional(),
|
|
380
|
+
changedBy: z.string().max(128).optional(),
|
|
381
|
+
}).strict();
|
|
382
|
+
const deleteBodySchema = z.object({
|
|
383
|
+
changedBy: z.string().max(128).optional(),
|
|
384
|
+
}).strict().default({});
|
|
385
|
+
router.post("/api/flags", validate(flagCreateSchema), asyncRoute(async (req, res) => {
|
|
386
|
+
const flag = await createFlag(postgresPool, {
|
|
387
|
+
key: req.body.key,
|
|
388
|
+
description: req.body.description,
|
|
389
|
+
enabled: req.body.enabled,
|
|
390
|
+
rolloutPercentage: req.body.rolloutPercentage,
|
|
391
|
+
changedBy: req.body.changedBy,
|
|
392
|
+
});
|
|
393
|
+
await invalidateFlagCache(redisClient, flag.key);
|
|
394
|
+
res.status(201).json({ flag: serializeFlag(flag) });
|
|
395
|
+
}));
|
|
396
|
+
router.get("/api/flags/:key", validateParams(flagKeyParamSchema), asyncRoute(async (req, res) => {
|
|
397
|
+
const flag = await getFlagByKey(postgresPool, req.params.key);
|
|
398
|
+
if (!flag) {
|
|
399
|
+
res.status(404).json({ message: "Feature flag not found" });
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const rules = await listFlagRules(postgresPool, req.params.key);
|
|
403
|
+
res.json({
|
|
404
|
+
flag: serializeFlag(flag, rules.length),
|
|
405
|
+
rules: rules.map(serializeRule),
|
|
406
|
+
});
|
|
407
|
+
}));
|
|
408
|
+
router.patch("/api/flags/:key", validateParams(flagKeyParamSchema), validate(flagUpdateSchema), asyncRoute(async (req, res) => {
|
|
409
|
+
const flag = await updateFlag(postgresPool, req.params.key, {
|
|
410
|
+
description: req.body.description,
|
|
411
|
+
enabled: req.body.enabled,
|
|
412
|
+
rolloutPercentage: req.body.rolloutPercentage,
|
|
413
|
+
changedBy: req.body.changedBy,
|
|
414
|
+
});
|
|
415
|
+
if (!flag) {
|
|
416
|
+
res.status(404).json({ message: "Feature flag not found" });
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
await invalidateFlagCache(redisClient, req.params.key);
|
|
420
|
+
const rules = await listFlagRules(postgresPool, req.params.key);
|
|
421
|
+
res.json({ flag: serializeFlag(flag, rules.length) });
|
|
422
|
+
}));
|
|
423
|
+
router.delete("/api/flags/:key", validateParams(flagKeyParamSchema), validate(deleteBodySchema), asyncRoute(async (req, res) => {
|
|
424
|
+
const deleted = await deleteFlag(postgresPool, req.params.key, req.body?.changedBy);
|
|
425
|
+
if (!deleted) {
|
|
426
|
+
res.status(404).json({ message: "Feature flag not found" });
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
await invalidateFlagCache(redisClient, req.params.key);
|
|
430
|
+
res.status(204).send();
|
|
431
|
+
}));
|
|
432
|
+
router.get("/api/flags/:key/rules", validateParams(flagKeyParamSchema), asyncRoute(async (req, res) => {
|
|
433
|
+
const flag = await getFlagByKey(postgresPool, req.params.key);
|
|
434
|
+
if (!flag) {
|
|
435
|
+
res.status(404).json({ message: "Feature flag not found" });
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
const rules = await listFlagRules(postgresPool, req.params.key);
|
|
439
|
+
res.json({ rules: rules.map(serializeRule) });
|
|
440
|
+
}));
|
|
441
|
+
router.post("/api/flags/:key/rules", validateParams(flagKeyParamSchema), validate(flagRuleSchema), asyncRoute(async (req, res) => {
|
|
442
|
+
const rule = await createFlagRule(postgresPool, {
|
|
443
|
+
flagKey: req.params.key,
|
|
444
|
+
attribute: req.body.attribute,
|
|
445
|
+
operator: req.body.operator,
|
|
446
|
+
value: req.body.value,
|
|
447
|
+
enabled: req.body.enabled,
|
|
448
|
+
changedBy: req.body.changedBy,
|
|
449
|
+
});
|
|
450
|
+
if (!rule) {
|
|
451
|
+
res.status(404).json({ message: "Feature flag not found" });
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
await invalidateFlagCache(redisClient, req.params.key);
|
|
455
|
+
res.status(201).json({ rule: serializeRule(rule) });
|
|
456
|
+
}));
|
|
457
|
+
router.patch("/api/flags/:key/rules/:ruleId", validateParams(flagKeyParamSchema.merge(idParamSchema("ruleId"))), validate(flagRuleSchema.partial()), asyncRoute(async (req, res) => {
|
|
458
|
+
const flag = await getFlagByKey(postgresPool, req.params.key);
|
|
459
|
+
if (!flag) {
|
|
460
|
+
res.status(404).json({ message: "Feature flag not found" });
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
const rule = await updateFlagRule(postgresPool, req.params.ruleId, {
|
|
464
|
+
attribute: req.body.attribute,
|
|
465
|
+
operator: req.body.operator,
|
|
466
|
+
value: req.body.value,
|
|
467
|
+
enabled: req.body.enabled,
|
|
468
|
+
changedBy: req.body.changedBy,
|
|
469
|
+
});
|
|
470
|
+
if (!rule) {
|
|
471
|
+
res.status(404).json({ message: "Feature flag rule not found" });
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
await invalidateFlagCache(redisClient, req.params.key);
|
|
475
|
+
res.json({ rule: serializeRule(rule) });
|
|
476
|
+
}));
|
|
477
|
+
router.delete("/api/flags/:key/rules/:ruleId", validateParams(flagKeyParamSchema.merge(idParamSchema("ruleId"))), validate(deleteBodySchema), asyncRoute(async (req, res) => {
|
|
478
|
+
const flag = await getFlagByKey(postgresPool, req.params.key);
|
|
479
|
+
if (!flag) {
|
|
480
|
+
res.status(404).json({ message: "Feature flag not found" });
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
const deleted = await deleteFlagRule(postgresPool, req.params.ruleId, req.body?.changedBy);
|
|
484
|
+
if (!deleted) {
|
|
485
|
+
res.status(404).json({ message: "Feature flag rule not found" });
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
await invalidateFlagCache(redisClient, req.params.key);
|
|
489
|
+
res.status(204).send();
|
|
490
|
+
}));
|
|
491
|
+
router.get("/api/flags/:key/audit-logs", validateParams(flagKeyParamSchema), asyncRoute(async (req, res) => {
|
|
492
|
+
const flag = await getFlagByKey(postgresPool, req.params.key);
|
|
493
|
+
if (!flag) {
|
|
494
|
+
res.status(404).json({ message: "Feature flag not found" });
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
const auditLogs = await listFlagAuditLogs(postgresPool, req.params.key);
|
|
498
|
+
res.json({ auditLogs: auditLogs.map(serializeAuditLog) });
|
|
499
|
+
}));
|
|
500
|
+
router.get("/api/workflows", async (req, res) => {
|
|
501
|
+
try {
|
|
502
|
+
const workflows = await listWorkflowDefinitions(postgresPool);
|
|
503
|
+
const executions = await listWorkflowExecutions(postgresPool, 200);
|
|
504
|
+
const executionSteps = await listWorkflowStepExecutionsByExecutionIds(postgresPool, executions.map((execution) => execution.id));
|
|
505
|
+
const workflowSteps = await listWorkflowStepsByWorkflowIds(postgresPool, workflows.map((workflow) => workflow.id));
|
|
506
|
+
const latestExecutions = latestByWorkflow(executions);
|
|
507
|
+
res.json({
|
|
508
|
+
workflows: workflows.map((workflow) => {
|
|
509
|
+
const latestExecution = latestExecutions.get(workflow.name);
|
|
510
|
+
const steps = executionSteps.get(latestExecution?.id) || [];
|
|
511
|
+
const definitionSteps = workflowSteps.get(workflow.id) || [];
|
|
512
|
+
return serializeWorkflowSummary(workflow, executions, definitionSteps, latestExecution, steps);
|
|
513
|
+
}),
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
catch (error) {
|
|
517
|
+
await dashboardError(res);
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
router.get("/api/workflows/:name", validateParams(workflowNameParamSchema), async (req, res) => {
|
|
521
|
+
try {
|
|
522
|
+
const workflow = await getLatestWorkflowDefinitionByName(postgresPool, req.params.name);
|
|
523
|
+
if (!workflow) {
|
|
524
|
+
res.status(404).json({ message: "Workflow not found" });
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
const executions = await listWorkflowExecutionsByWorkflowName(postgresPool, req.params.name, 50);
|
|
528
|
+
const executionSteps = await listWorkflowStepExecutionsByExecutionIds(postgresPool, executions.map((execution) => execution.id));
|
|
529
|
+
const workflowSteps = await listWorkflowStepsByWorkflowIds(postgresPool, [workflow.id]);
|
|
530
|
+
const latestExecution = executions[0];
|
|
531
|
+
const latestSteps = executionSteps.get(latestExecution?.id) || [];
|
|
532
|
+
const definitionSteps = workflowSteps.get(workflow.id) || [];
|
|
533
|
+
res.json({
|
|
534
|
+
workflow: serializeWorkflowSummary(workflow, executions, definitionSteps, latestExecution, latestSteps),
|
|
535
|
+
executions: executions.map((execution) => serializeExecution(execution, executionSteps.get(execution.id) || [])),
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
catch (error) {
|
|
539
|
+
await dashboardError(res);
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
router.get("/api/executions", validateQuery(optionalWorkflowQuerySchema), async (req, res) => {
|
|
543
|
+
try {
|
|
544
|
+
const workflowName = typeof req.validatedQuery?.workflow === "string" ? req.validatedQuery.workflow : undefined;
|
|
545
|
+
const executions = workflowName
|
|
546
|
+
? await listWorkflowExecutionsByWorkflowName(postgresPool, workflowName, 50)
|
|
547
|
+
: await listWorkflowExecutions(postgresPool, 50);
|
|
548
|
+
const executionSteps = await listWorkflowStepExecutionsByExecutionIds(postgresPool, executions.map((execution) => execution.id));
|
|
549
|
+
res.json({ executions: executions.map((execution) => serializeExecution(execution, executionSteps.get(execution.id) || [])) });
|
|
550
|
+
}
|
|
551
|
+
catch (error) {
|
|
552
|
+
await dashboardError(res);
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
router.get("/api/executions/:executionId", validateParams(idParamSchema("executionId")), async (req, res) => {
|
|
556
|
+
try {
|
|
557
|
+
const execution = await getWorkflowExecution(postgresPool, req.params.executionId);
|
|
558
|
+
if (!execution) {
|
|
559
|
+
res.status(404).json({ message: "Workflow execution not found" });
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
const steps = await getWorkflowExecutionSteps(postgresPool, req.params.executionId);
|
|
563
|
+
res.json({ execution: serializeExecution(execution, steps) });
|
|
564
|
+
}
|
|
565
|
+
catch (error) {
|
|
566
|
+
await dashboardError(res);
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
router.get("/api/traces", async (req, res) => {
|
|
570
|
+
try {
|
|
571
|
+
const traces = await listRequestTraces(postgresPool, 50);
|
|
572
|
+
const traceSpans = new Map();
|
|
573
|
+
await Promise.all(traces.map(async (trace) => {
|
|
574
|
+
traceSpans.set(trace.id, await getTraceSpans(postgresPool, trace.id));
|
|
575
|
+
}));
|
|
576
|
+
res.json({ traces: traces.map((trace) => serializeTrace(trace, traceSpans.get(trace.id) || [])) });
|
|
577
|
+
}
|
|
578
|
+
catch (error) {
|
|
579
|
+
await dashboardError(res);
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
router.get("/api/traces/:traceId", validateParams(idParamSchema("traceId")), async (req, res) => {
|
|
583
|
+
try {
|
|
584
|
+
const trace = await getRequestTrace(postgresPool, req.params.traceId);
|
|
585
|
+
if (!trace) {
|
|
586
|
+
res.status(404).json({ message: "Trace not found" });
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
const [spans, errors] = await Promise.all([
|
|
590
|
+
getTraceSpans(postgresPool, req.params.traceId),
|
|
591
|
+
getErrorsByTrace(postgresPool, req.params.traceId),
|
|
592
|
+
]);
|
|
593
|
+
res.json({
|
|
594
|
+
trace: serializeTrace(trace, spans),
|
|
595
|
+
errors: errors.map(serializeError),
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
catch (error) {
|
|
599
|
+
await dashboardError(res);
|
|
600
|
+
}
|
|
601
|
+
});
|
|
602
|
+
router.get("/api/errors", validateQuery(optionalTraceQuerySchema), async (req, res) => {
|
|
603
|
+
try {
|
|
604
|
+
const traceId = typeof req.validatedQuery?.traceId === "string" ? req.validatedQuery.traceId : undefined;
|
|
605
|
+
const errors = traceId ? await getErrorsByTrace(postgresPool, traceId) : await listErrors(postgresPool, 50);
|
|
606
|
+
res.json({ errors: errors.map(serializeError) });
|
|
607
|
+
}
|
|
608
|
+
catch (error) {
|
|
609
|
+
await dashboardError(res);
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
router.get("/api/errors/:errorId", validateParams(idParamSchema("errorId")), async (req, res) => {
|
|
613
|
+
try {
|
|
614
|
+
const error = await getErrorById(postgresPool, req.params.errorId);
|
|
615
|
+
if (!error) {
|
|
616
|
+
res.status(404).json({ message: "Error not found" });
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
res.json({ error: serializeError(error) });
|
|
620
|
+
}
|
|
621
|
+
catch (error) {
|
|
622
|
+
await dashboardError(res);
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
const aiKeySchema = z.object({
|
|
626
|
+
groqApiKey: z.string().min(1).max(512).regex(/^[^\r\n]+$/).optional(),
|
|
627
|
+
groqModel: z.string().min(1).max(128).regex(/^[a-zA-Z0-9._:/-]+$/).optional(),
|
|
628
|
+
}).strict().refine((d) => d.groqApiKey || d.groqModel, { message: "groqApiKey or groqModel is required" });
|
|
629
|
+
router.post("/api/settings/ai-key", validate(aiKeySchema), async (req, res) => {
|
|
630
|
+
try {
|
|
631
|
+
await saveFlowwatchEnv({
|
|
632
|
+
...(req.body.groqApiKey ? { groqApiKey: req.body.groqApiKey } : {}),
|
|
633
|
+
...(req.body.groqModel ? { groqModel: req.body.groqModel } : {}),
|
|
634
|
+
});
|
|
635
|
+
res.json({ success: true });
|
|
636
|
+
}
|
|
637
|
+
catch (error) {
|
|
638
|
+
await dashboardError(res);
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
const settingsSchema = z.object({
|
|
642
|
+
environment: z.string().max(64).regex(/^[a-zA-Z0-9._:-]*$/).optional(),
|
|
643
|
+
dashboardPath: z.string().max(256).regex(/^\/[a-zA-Z0-9/_:-]*$/).optional(),
|
|
644
|
+
dashboardEnabled: z.boolean().optional(),
|
|
645
|
+
dashboardAuthEnabled: z.boolean().optional(),
|
|
646
|
+
workerEnabled: z.boolean().optional(),
|
|
647
|
+
workerConcurrency: z.number().int().min(1).max(100).optional(),
|
|
648
|
+
queuePrefix: z.string().max(64).regex(/^[a-zA-Z0-9:_-]*$/).optional(),
|
|
649
|
+
autoRunMigrations: z.boolean().optional(),
|
|
650
|
+
groqModel: z.string().max(128).regex(/^[a-zA-Z0-9._:/-]+$/).optional(),
|
|
651
|
+
}).strict();
|
|
652
|
+
router.post("/api/settings", validate(settingsSchema), async (req, res) => {
|
|
653
|
+
try {
|
|
654
|
+
const { environment, dashboardPath, dashboardEnabled, dashboardAuthEnabled, workerEnabled, workerConcurrency, queuePrefix, autoRunMigrations, groqModel, } = req.body;
|
|
655
|
+
// Update in-memory config
|
|
656
|
+
if (environment !== undefined)
|
|
657
|
+
config.runtime.environment = environment;
|
|
658
|
+
if (dashboardPath !== undefined)
|
|
659
|
+
config.dashboard.path = dashboardPath;
|
|
660
|
+
if (dashboardEnabled !== undefined)
|
|
661
|
+
config.dashboard.enabled = dashboardEnabled;
|
|
662
|
+
if (workerEnabled !== undefined)
|
|
663
|
+
config.worker.enabled = workerEnabled;
|
|
664
|
+
if (workerConcurrency !== undefined)
|
|
665
|
+
config.worker.workflowConcurrency = Number(workerConcurrency);
|
|
666
|
+
if (queuePrefix !== undefined)
|
|
667
|
+
config.worker.queuePrefix = queuePrefix;
|
|
668
|
+
if (autoRunMigrations !== undefined)
|
|
669
|
+
config.migrations.autoRun = autoRunMigrations;
|
|
670
|
+
if (groqModel !== undefined) {
|
|
671
|
+
await saveFlowwatchEnv({ groqModel });
|
|
672
|
+
}
|
|
673
|
+
res.json({ success: true, settings: serializeSettings(config) });
|
|
674
|
+
}
|
|
675
|
+
catch (error) {
|
|
676
|
+
await dashboardError(res);
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
const aiChatSchema = z.object({
|
|
680
|
+
message: z.string().min(1).max(4096),
|
|
681
|
+
history: z.array(z.object({
|
|
682
|
+
role: z.enum(["user", "assistant"]),
|
|
683
|
+
content: z.string().max(4096),
|
|
684
|
+
}).strict()).max(50).default([]),
|
|
685
|
+
model: z.string().max(128).regex(/^[a-zA-Z0-9._:/-]+$/).optional(),
|
|
686
|
+
}).strict();
|
|
687
|
+
router.post("/api/ai-chat", validate(aiChatSchema), async (req, res) => {
|
|
688
|
+
try {
|
|
689
|
+
if (!isGroqApiKeyConfigured()) {
|
|
690
|
+
res.status(428).json({
|
|
691
|
+
error: {
|
|
692
|
+
code: "groq_api_key_missing",
|
|
693
|
+
message: "Add your Groq API key in Settings → AI Configuration.",
|
|
694
|
+
},
|
|
695
|
+
});
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
const sanitizedHistory = req.body.history.filter((m) => m.role !== "system");
|
|
699
|
+
const context = await buildAiInsightContext(options);
|
|
700
|
+
const responseText = await askGroqAi(context, req.body.message, sanitizedHistory, req.body.model);
|
|
701
|
+
res.json({ response: responseText });
|
|
702
|
+
}
|
|
703
|
+
catch (error) {
|
|
704
|
+
await handleAiError({ pool: postgresPool, elasticsearchClient }, res, error, "POST /api/ai-chat");
|
|
705
|
+
}
|
|
706
|
+
});
|
|
707
|
+
return router;
|
|
708
|
+
}
|