triagent 0.1.0-alpha13 → 0.1.0-alpha18
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/package.json +3 -4
- package/src/cli/config.ts +96 -0
- package/src/index.ts +201 -3
- package/src/integrations/elasticsearch/client.ts +210 -0
- package/src/integrations/grafana/client.ts +186 -0
- package/src/integrations/kubernetes/multi-cluster.ts +199 -0
- package/src/integrations/kubernetes/types.ts +24 -0
- package/src/integrations/loki/client.ts +219 -0
- package/src/integrations/prometheus/client.ts +163 -0
- package/src/integrations/slack/client.ts +265 -0
- package/src/integrations/teams/client.ts +199 -0
- package/src/mastra/agents/debugger.ts +152 -108
- package/src/mastra/tools/approval-store.ts +180 -0
- package/src/mastra/tools/cli.ts +94 -2
- package/src/mastra/tools/cost.ts +389 -0
- package/src/mastra/tools/logs.ts +210 -0
- package/src/mastra/tools/network.ts +253 -0
- package/src/mastra/tools/prometheus.ts +221 -0
- package/src/mastra/tools/remediation.ts +365 -0
- package/src/mastra/tools/runbook.ts +186 -0
- package/src/server/routes/history.ts +207 -0
- package/src/server/routes/notifications.ts +236 -0
- package/src/server/webhook.ts +36 -2
- package/src/storage/index.ts +3 -0
- package/src/storage/investigation-history.ts +277 -0
- package/src/storage/runbook-index.ts +330 -0
- package/src/storage/types.ts +72 -0
- package/src/tui/app.tsx +492 -76
- package/src/tui/components/approval-dialog.tsx +156 -0
- package/src/tui/components/approval-modal.tsx +278 -0
- package/src/tui/components/index.ts +38 -0
- package/src/tui/components/styled-span.tsx +24 -0
- package/src/tui/components/timeline.tsx +223 -0
- package/src/tui/components/toast.tsx +101 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "triagent",
|
|
3
|
-
"version": "0.1.0-
|
|
3
|
+
"version": "0.1.0-alpha18",
|
|
4
4
|
"description": "AI-powered Kubernetes debugging agent with terminal UI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -47,11 +47,11 @@
|
|
|
47
47
|
"@ai-sdk/openai": "^1.3.0",
|
|
48
48
|
"@bashlet/sdk": "^0.1.0-alpha1",
|
|
49
49
|
"@mastra/core": "^1.0.0-beta.21",
|
|
50
|
+
"@opentui-ui/dialog": "^0.1.2",
|
|
51
|
+
"@opentui-ui/toast": "^0.0.5",
|
|
50
52
|
"@opentui/core": "^0.1.72",
|
|
51
53
|
"@opentui/solid": "^0.1.72",
|
|
52
54
|
"hono": "^4.6.0",
|
|
53
|
-
"marked": "^17.0.1",
|
|
54
|
-
"marked-terminal": "^7.3.0",
|
|
55
55
|
"opentui-spinner": "^0.0.6",
|
|
56
56
|
"solid-js": "1.9.9",
|
|
57
57
|
"triagent": "^0.1.0-alpha1",
|
|
@@ -59,7 +59,6 @@
|
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@types/babel__core": "^7.20.5",
|
|
62
|
-
"@types/marked-terminal": "^6.1.1",
|
|
63
62
|
"@types/node": "^22.0.0",
|
|
64
63
|
"bun-types": "^1.2.0",
|
|
65
64
|
"typescript": "^5.7.0"
|
package/src/cli/config.ts
CHANGED
|
@@ -8,6 +8,65 @@ export interface CodebaseEntry {
|
|
|
8
8
|
path: string;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
export interface ClusterConfig {
|
|
12
|
+
name: string;
|
|
13
|
+
context: string;
|
|
14
|
+
kubeConfigPath?: string;
|
|
15
|
+
environment?: "development" | "staging" | "production";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PrometheusConfig {
|
|
19
|
+
url: string;
|
|
20
|
+
auth?: { token: string };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface GrafanaConfig {
|
|
24
|
+
url: string;
|
|
25
|
+
apiKey: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ElasticsearchConfig {
|
|
29
|
+
url: string;
|
|
30
|
+
index: string;
|
|
31
|
+
auth?: { apiKey: string };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface LokiConfig {
|
|
35
|
+
url: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CloudWatchConfig {
|
|
39
|
+
region: string;
|
|
40
|
+
logGroupPrefix?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface SlackConfig {
|
|
44
|
+
webhookUrl: string;
|
|
45
|
+
botToken?: string;
|
|
46
|
+
defaultChannel?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface TeamsConfig {
|
|
50
|
+
webhookUrl: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface RunbookConfig {
|
|
54
|
+
paths: string[];
|
|
55
|
+
gitRepos?: string[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface CostAnalysisConfig {
|
|
59
|
+
provider?: "aws" | "gcp" | "azure";
|
|
60
|
+
hourlyRates?: {
|
|
61
|
+
cpu: number;
|
|
62
|
+
memory: number;
|
|
63
|
+
storage: number;
|
|
64
|
+
};
|
|
65
|
+
businessImpact?: {
|
|
66
|
+
revenuePerMinute?: number;
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
11
70
|
export interface StoredConfig {
|
|
12
71
|
aiProvider?: AIProvider;
|
|
13
72
|
aiModel?: string;
|
|
@@ -17,11 +76,35 @@ export interface StoredConfig {
|
|
|
17
76
|
codebasePath?: string; // Deprecated: use codebasePaths instead
|
|
18
77
|
codebasePaths?: CodebaseEntry[];
|
|
19
78
|
kubeConfigPath?: string;
|
|
79
|
+
|
|
80
|
+
// Phase 1: Foundation
|
|
81
|
+
historyRetentionDays?: number;
|
|
82
|
+
clusters?: ClusterConfig[];
|
|
83
|
+
activeCluster?: string;
|
|
84
|
+
|
|
85
|
+
// Phase 2: Observability
|
|
86
|
+
prometheus?: PrometheusConfig;
|
|
87
|
+
grafana?: GrafanaConfig;
|
|
88
|
+
logProvider?: "elasticsearch" | "loki" | "cloudwatch";
|
|
89
|
+
elasticsearch?: ElasticsearchConfig;
|
|
90
|
+
loki?: LokiConfig;
|
|
91
|
+
cloudwatch?: CloudWatchConfig;
|
|
92
|
+
|
|
93
|
+
// Phase 3: Operations
|
|
94
|
+
runbooks?: RunbookConfig;
|
|
95
|
+
|
|
96
|
+
// Phase 4: Communication & Cost
|
|
97
|
+
notifications?: {
|
|
98
|
+
slack?: SlackConfig;
|
|
99
|
+
teams?: TeamsConfig;
|
|
100
|
+
};
|
|
101
|
+
costAnalysis?: CostAnalysisConfig;
|
|
20
102
|
}
|
|
21
103
|
|
|
22
104
|
const CONFIG_DIR = join(homedir(), ".config", "triagent");
|
|
23
105
|
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
24
106
|
const TRIAGENT_MD_FILE = join(CONFIG_DIR, "TRIAGENT.md");
|
|
107
|
+
const RUNBOOK_MD_FILE = join(CONFIG_DIR, "RUNBOOK.md");
|
|
25
108
|
|
|
26
109
|
export async function getConfigPath(): Promise<string> {
|
|
27
110
|
return CONFIG_FILE;
|
|
@@ -40,6 +123,19 @@ export async function loadTriagentMd(): Promise<string | null> {
|
|
|
40
123
|
}
|
|
41
124
|
}
|
|
42
125
|
|
|
126
|
+
export async function loadRunbookMd(): Promise<string | null> {
|
|
127
|
+
try {
|
|
128
|
+
const content = await readFile(RUNBOOK_MD_FILE, "utf-8");
|
|
129
|
+
return content.trim();
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function getRunbookMdPath(): Promise<string> {
|
|
136
|
+
return RUNBOOK_MD_FILE;
|
|
137
|
+
}
|
|
138
|
+
|
|
43
139
|
export async function loadStoredConfig(): Promise<StoredConfig> {
|
|
44
140
|
try {
|
|
45
141
|
const content = await readFile(CONFIG_FILE, "utf-8");
|
package/src/index.ts
CHANGED
|
@@ -16,10 +16,15 @@ import {
|
|
|
16
16
|
import type { AIProvider } from "./config.js";
|
|
17
17
|
|
|
18
18
|
interface CliArgs {
|
|
19
|
-
command: "run" | "config";
|
|
19
|
+
command: "run" | "config" | "cluster";
|
|
20
20
|
configAction?: "set" | "get" | "list" | "path";
|
|
21
21
|
configKey?: string;
|
|
22
22
|
configValue?: string;
|
|
23
|
+
clusterAction?: "add" | "remove" | "list" | "use" | "status";
|
|
24
|
+
clusterName?: string;
|
|
25
|
+
clusterContext?: string;
|
|
26
|
+
clusterKubeConfig?: string;
|
|
27
|
+
clusterEnvironment?: string;
|
|
23
28
|
webhookOnly: boolean;
|
|
24
29
|
incident: string | null;
|
|
25
30
|
help: boolean;
|
|
@@ -45,6 +50,30 @@ function parseArgs(): CliArgs {
|
|
|
45
50
|
return result;
|
|
46
51
|
}
|
|
47
52
|
|
|
53
|
+
// Check for cluster subcommand
|
|
54
|
+
if (args[0] === "cluster") {
|
|
55
|
+
result.command = "cluster";
|
|
56
|
+
result.clusterAction = args[1] as "add" | "remove" | "list" | "use" | "status";
|
|
57
|
+
|
|
58
|
+
// Parse cluster sub-command arguments
|
|
59
|
+
for (let i = 2; i < args.length; i++) {
|
|
60
|
+
const arg = args[i];
|
|
61
|
+
if (arg === "--name" || arg === "-n") {
|
|
62
|
+
result.clusterName = args[++i];
|
|
63
|
+
} else if (arg === "--context" || arg === "-c") {
|
|
64
|
+
result.clusterContext = args[++i];
|
|
65
|
+
} else if (arg === "--kubeconfig" || arg === "-k") {
|
|
66
|
+
result.clusterKubeConfig = args[++i];
|
|
67
|
+
} else if (arg === "--environment" || arg === "-e") {
|
|
68
|
+
result.clusterEnvironment = args[++i];
|
|
69
|
+
} else if (!arg.startsWith("-")) {
|
|
70
|
+
// Positional argument - cluster name
|
|
71
|
+
result.clusterName = arg;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
|
|
48
77
|
for (let i = 0; i < args.length; i++) {
|
|
49
78
|
const arg = args[i];
|
|
50
79
|
|
|
@@ -69,6 +98,7 @@ function printHelp(): void {
|
|
|
69
98
|
USAGE:
|
|
70
99
|
triagent [OPTIONS]
|
|
71
100
|
triagent config <action> [key] [value]
|
|
101
|
+
triagent cluster <action> [options]
|
|
72
102
|
|
|
73
103
|
OPTIONS:
|
|
74
104
|
-h, --help Show this help message
|
|
@@ -98,6 +128,19 @@ CONFIG KEYS:
|
|
|
98
128
|
]
|
|
99
129
|
Each codebase will be mounted at /workspace/<name> in the sandbox.
|
|
100
130
|
|
|
131
|
+
CLUSTER COMMANDS:
|
|
132
|
+
triagent cluster add <name> --context <ctx> Add a cluster
|
|
133
|
+
triagent cluster remove <name> Remove a cluster
|
|
134
|
+
triagent cluster list List all clusters
|
|
135
|
+
triagent cluster use <name> Set active cluster
|
|
136
|
+
triagent cluster status [name] Check cluster status
|
|
137
|
+
|
|
138
|
+
CLUSTER OPTIONS:
|
|
139
|
+
-n, --name Cluster name
|
|
140
|
+
-c, --context Kubernetes context name
|
|
141
|
+
-k, --kubeconfig Path to kubeconfig file
|
|
142
|
+
-e, --environment Environment (development, staging, production)
|
|
143
|
+
|
|
101
144
|
MODES:
|
|
102
145
|
Interactive (default):
|
|
103
146
|
Run with no arguments to start the interactive TUI.
|
|
@@ -108,9 +151,12 @@ MODES:
|
|
|
108
151
|
incident webhooks from alerting systems.
|
|
109
152
|
|
|
110
153
|
Endpoints:
|
|
111
|
-
POST /webhook/incident
|
|
154
|
+
POST /webhook/incident - Submit an incident
|
|
112
155
|
GET /investigations/:id - Get investigation results
|
|
113
|
-
GET /
|
|
156
|
+
GET /history - List investigation history
|
|
157
|
+
GET /history/:id - Get investigation details
|
|
158
|
+
GET /history/stats - Get investigation statistics
|
|
159
|
+
GET /health - Health check
|
|
114
160
|
|
|
115
161
|
Direct Input:
|
|
116
162
|
Use --incident "description" for one-shot debugging.
|
|
@@ -137,6 +183,11 @@ EXAMPLES:
|
|
|
137
183
|
# Direct incident investigation
|
|
138
184
|
triagent -i "API gateway returning 503 errors"
|
|
139
185
|
|
|
186
|
+
# Multi-cluster management
|
|
187
|
+
triagent cluster add prod --context prod-cluster -e production
|
|
188
|
+
triagent cluster use prod
|
|
189
|
+
triagent cluster status
|
|
190
|
+
|
|
140
191
|
# Submit via curl (webhook mode)
|
|
141
192
|
curl -X POST http://localhost:3000/webhook/incident \\
|
|
142
193
|
-H "Content-Type: application/json" \\
|
|
@@ -185,6 +236,147 @@ async function runDirectIncident(description: string): Promise<void> {
|
|
|
185
236
|
}
|
|
186
237
|
}
|
|
187
238
|
|
|
239
|
+
import { initClusterManager, getClusterManager } from "./integrations/kubernetes/multi-cluster.js";
|
|
240
|
+
import type { ClusterConfig } from "./cli/config.js";
|
|
241
|
+
|
|
242
|
+
async function handleClusterCommand(args: CliArgs): Promise<void> {
|
|
243
|
+
const config = await loadStoredConfig();
|
|
244
|
+
const clusterManager = initClusterManager(config.clusters, config.activeCluster);
|
|
245
|
+
|
|
246
|
+
switch (args.clusterAction) {
|
|
247
|
+
case "add": {
|
|
248
|
+
if (!args.clusterName) {
|
|
249
|
+
console.error("Usage: triagent cluster add <name> --context <context>");
|
|
250
|
+
process.exit(1);
|
|
251
|
+
}
|
|
252
|
+
const context = args.clusterContext || args.clusterName;
|
|
253
|
+
const newCluster: ClusterConfig = {
|
|
254
|
+
name: args.clusterName,
|
|
255
|
+
context,
|
|
256
|
+
kubeConfigPath: args.clusterKubeConfig,
|
|
257
|
+
environment: args.clusterEnvironment as ClusterConfig["environment"],
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
await clusterManager.addCluster(newCluster);
|
|
261
|
+
|
|
262
|
+
// Save to config
|
|
263
|
+
config.clusters = config.clusters || [];
|
|
264
|
+
config.clusters.push(newCluster);
|
|
265
|
+
await saveStoredConfig(config);
|
|
266
|
+
|
|
267
|
+
console.log(`✅ Added cluster: ${args.clusterName} (context: ${context})`);
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
case "remove": {
|
|
271
|
+
if (!args.clusterName) {
|
|
272
|
+
console.error("Usage: triagent cluster remove <name>");
|
|
273
|
+
process.exit(1);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const removed = await clusterManager.removeCluster(args.clusterName);
|
|
277
|
+
if (!removed) {
|
|
278
|
+
console.error(`Cluster not found: ${args.clusterName}`);
|
|
279
|
+
process.exit(1);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Remove from config
|
|
283
|
+
config.clusters = config.clusters?.filter((c) => c.name !== args.clusterName);
|
|
284
|
+
if (config.activeCluster === args.clusterName) {
|
|
285
|
+
config.activeCluster = undefined;
|
|
286
|
+
}
|
|
287
|
+
await saveStoredConfig(config);
|
|
288
|
+
|
|
289
|
+
console.log(`✅ Removed cluster: ${args.clusterName}`);
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
case "list": {
|
|
293
|
+
const clusters = clusterManager.listClusters();
|
|
294
|
+
if (clusters.length === 0) {
|
|
295
|
+
console.log("No clusters configured.");
|
|
296
|
+
console.log("\nDiscover available contexts:");
|
|
297
|
+
const discovered = await clusterManager.discoverClusters();
|
|
298
|
+
if (discovered.length > 0) {
|
|
299
|
+
console.log("\nAvailable Kubernetes contexts:");
|
|
300
|
+
for (const c of discovered) {
|
|
301
|
+
console.log(` - ${c.context} (${c.server})`);
|
|
302
|
+
}
|
|
303
|
+
console.log("\nAdd a cluster with: triagent cluster add <name> --context <context>");
|
|
304
|
+
} else {
|
|
305
|
+
console.log("No Kubernetes contexts found.");
|
|
306
|
+
}
|
|
307
|
+
} else {
|
|
308
|
+
console.log("Configured clusters:\n");
|
|
309
|
+
for (const c of clusters) {
|
|
310
|
+
const active = c.isActive ? " (active)" : "";
|
|
311
|
+
const env = c.environment ? ` [${c.environment}]` : "";
|
|
312
|
+
console.log(` ${c.name}${active}${env}`);
|
|
313
|
+
console.log(` context: ${c.context}`);
|
|
314
|
+
if (c.kubeConfigPath) {
|
|
315
|
+
console.log(` kubeconfig: ${c.kubeConfigPath}`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
case "use": {
|
|
322
|
+
if (!args.clusterName) {
|
|
323
|
+
console.error("Usage: triagent cluster use <name>");
|
|
324
|
+
process.exit(1);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const success = await clusterManager.setActiveCluster(args.clusterName);
|
|
328
|
+
if (!success) {
|
|
329
|
+
console.error(`Cluster not found: ${args.clusterName}`);
|
|
330
|
+
process.exit(1);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Save to config
|
|
334
|
+
config.activeCluster = args.clusterName;
|
|
335
|
+
await saveStoredConfig(config);
|
|
336
|
+
|
|
337
|
+
console.log(`✅ Active cluster set to: ${args.clusterName}`);
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
case "status": {
|
|
341
|
+
const clusterName = args.clusterName;
|
|
342
|
+
if (clusterName) {
|
|
343
|
+
const status = await clusterManager.checkClusterStatus(clusterName);
|
|
344
|
+
console.log(`Cluster: ${status.name}`);
|
|
345
|
+
console.log(` Connected: ${status.connected ? "✅ Yes" : "❌ No"}`);
|
|
346
|
+
if (status.connected) {
|
|
347
|
+
console.log(` Version: ${status.version}`);
|
|
348
|
+
console.log(` Nodes: ${status.nodeCount}`);
|
|
349
|
+
}
|
|
350
|
+
if (status.error) {
|
|
351
|
+
console.log(` Error: ${status.error}`);
|
|
352
|
+
}
|
|
353
|
+
} else {
|
|
354
|
+
const clusters = clusterManager.listClusters();
|
|
355
|
+
if (clusters.length === 0) {
|
|
356
|
+
console.log("No clusters configured.");
|
|
357
|
+
} else {
|
|
358
|
+
console.log("Cluster status:\n");
|
|
359
|
+
for (const c of clusters) {
|
|
360
|
+
const status = await clusterManager.checkClusterStatus(c.name);
|
|
361
|
+
const active = c.isActive ? " (active)" : "";
|
|
362
|
+
const connected = status.connected ? "✅" : "❌";
|
|
363
|
+
console.log(` ${connected} ${c.name}${active}`);
|
|
364
|
+
if (status.connected) {
|
|
365
|
+
console.log(` v${status.version}, ${status.nodeCount} nodes`);
|
|
366
|
+
} else if (status.error) {
|
|
367
|
+
console.log(` Error: ${status.error}`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
default:
|
|
375
|
+
console.error("Usage: triagent cluster <add|remove|list|use|status> [options]");
|
|
376
|
+
process.exit(1);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
188
380
|
async function handleConfigCommand(args: CliArgs): Promise<void> {
|
|
189
381
|
const validKeys: (keyof StoredConfig)[] = [
|
|
190
382
|
"aiProvider",
|
|
@@ -273,6 +465,12 @@ async function main(): Promise<void> {
|
|
|
273
465
|
process.exit(0);
|
|
274
466
|
}
|
|
275
467
|
|
|
468
|
+
// Handle cluster command
|
|
469
|
+
if (args.command === "cluster") {
|
|
470
|
+
await handleClusterCommand(args);
|
|
471
|
+
process.exit(0);
|
|
472
|
+
}
|
|
473
|
+
|
|
276
474
|
// Load configuration
|
|
277
475
|
let config;
|
|
278
476
|
try {
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import type { ElasticsearchConfig } from "../../cli/config.js";
|
|
2
|
+
|
|
3
|
+
export interface ESSearchResult {
|
|
4
|
+
hits: {
|
|
5
|
+
total: { value: number; relation: string };
|
|
6
|
+
hits: Array<{
|
|
7
|
+
_index: string;
|
|
8
|
+
_id: string;
|
|
9
|
+
_source: Record<string, unknown>;
|
|
10
|
+
sort?: unknown[];
|
|
11
|
+
}>;
|
|
12
|
+
};
|
|
13
|
+
aggregations?: Record<string, unknown>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ESLogEntry {
|
|
17
|
+
timestamp: string;
|
|
18
|
+
message: string;
|
|
19
|
+
level?: string;
|
|
20
|
+
pod?: string;
|
|
21
|
+
namespace?: string;
|
|
22
|
+
container?: string;
|
|
23
|
+
[key: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class ElasticsearchClient {
|
|
27
|
+
private baseUrl: string;
|
|
28
|
+
private index: string;
|
|
29
|
+
private apiKey?: string;
|
|
30
|
+
|
|
31
|
+
constructor(config: ElasticsearchConfig) {
|
|
32
|
+
this.baseUrl = config.url.replace(/\/$/, "");
|
|
33
|
+
this.index = config.index;
|
|
34
|
+
this.apiKey = config.auth?.apiKey;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private async request<T>(
|
|
38
|
+
endpoint: string,
|
|
39
|
+
options?: Omit<RequestInit, 'body'> & { body?: unknown }
|
|
40
|
+
): Promise<T> {
|
|
41
|
+
const headers: Record<string, string> = {
|
|
42
|
+
"Accept": "application/json",
|
|
43
|
+
"Content-Type": "application/json",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
if (this.apiKey) {
|
|
47
|
+
headers["Authorization"] = `ApiKey ${this.apiKey}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const response = await fetch(`${this.baseUrl}${endpoint}`, {
|
|
51
|
+
...options,
|
|
52
|
+
headers: { ...headers, ...options?.headers },
|
|
53
|
+
body: options?.body ? JSON.stringify(options.body) : undefined,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
const text = await response.text();
|
|
58
|
+
throw new Error(`Elasticsearch API error: ${response.status} ${text}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return response.json() as Promise<T>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async search(options: {
|
|
65
|
+
query: string;
|
|
66
|
+
timeRange?: { start: string; end?: string };
|
|
67
|
+
limit?: number;
|
|
68
|
+
sort?: "asc" | "desc";
|
|
69
|
+
}): Promise<ESLogEntry[]> {
|
|
70
|
+
const { query, timeRange, limit = 100, sort = "desc" } = options;
|
|
71
|
+
|
|
72
|
+
const must: unknown[] = [
|
|
73
|
+
{
|
|
74
|
+
query_string: {
|
|
75
|
+
query,
|
|
76
|
+
default_field: "message",
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
if (timeRange) {
|
|
82
|
+
const range: Record<string, string> = { gte: timeRange.start };
|
|
83
|
+
if (timeRange.end) {
|
|
84
|
+
range.lte = timeRange.end;
|
|
85
|
+
}
|
|
86
|
+
must.push({
|
|
87
|
+
range: {
|
|
88
|
+
"@timestamp": range,
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const body = {
|
|
94
|
+
query: {
|
|
95
|
+
bool: { must },
|
|
96
|
+
},
|
|
97
|
+
size: limit,
|
|
98
|
+
sort: [{ "@timestamp": { order: sort } }],
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const result = await this.request<ESSearchResult>(`/${this.index}/_search`, {
|
|
102
|
+
method: "POST",
|
|
103
|
+
body,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return result.hits.hits.map((hit) => {
|
|
107
|
+
const source = hit._source as Record<string, unknown>;
|
|
108
|
+
const k8s = source.kubernetes as Record<string, unknown> | undefined;
|
|
109
|
+
const k8sPod = k8s?.pod as Record<string, unknown> | undefined;
|
|
110
|
+
const k8sContainer = k8s?.container as Record<string, unknown> | undefined;
|
|
111
|
+
return {
|
|
112
|
+
timestamp: (source["@timestamp"] as string) || new Date().toISOString(),
|
|
113
|
+
message: (source.message as string) || JSON.stringify(source),
|
|
114
|
+
level: source.level as string | undefined,
|
|
115
|
+
pod: (k8sPod?.name || source.pod) as string | undefined,
|
|
116
|
+
namespace: (k8s?.namespace || source.namespace) as string | undefined,
|
|
117
|
+
container: (k8sContainer?.name || source.container) as string | undefined,
|
|
118
|
+
...source,
|
|
119
|
+
};
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async aggregate(options: {
|
|
124
|
+
query: string;
|
|
125
|
+
field: string;
|
|
126
|
+
timeRange?: { start: string; end?: string };
|
|
127
|
+
interval?: string;
|
|
128
|
+
}): Promise<Array<{ key: string; count: number }>> {
|
|
129
|
+
const { query, field, timeRange, interval = "1m" } = options;
|
|
130
|
+
|
|
131
|
+
const must: unknown[] = [
|
|
132
|
+
{
|
|
133
|
+
query_string: {
|
|
134
|
+
query,
|
|
135
|
+
default_field: "message",
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
if (timeRange) {
|
|
141
|
+
const range: Record<string, string> = { gte: timeRange.start };
|
|
142
|
+
if (timeRange.end) {
|
|
143
|
+
range.lte = timeRange.end;
|
|
144
|
+
}
|
|
145
|
+
must.push({
|
|
146
|
+
range: {
|
|
147
|
+
"@timestamp": range,
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const body = {
|
|
153
|
+
query: {
|
|
154
|
+
bool: { must },
|
|
155
|
+
},
|
|
156
|
+
size: 0,
|
|
157
|
+
aggs: {
|
|
158
|
+
by_field: {
|
|
159
|
+
terms: { field, size: 50 },
|
|
160
|
+
},
|
|
161
|
+
over_time: {
|
|
162
|
+
date_histogram: {
|
|
163
|
+
field: "@timestamp",
|
|
164
|
+
fixed_interval: interval,
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const result = await this.request<{
|
|
171
|
+
aggregations: {
|
|
172
|
+
by_field: { buckets: Array<{ key: string; doc_count: number }> };
|
|
173
|
+
over_time: { buckets: Array<{ key_as_string: string; doc_count: number }> };
|
|
174
|
+
};
|
|
175
|
+
}>(`/${this.index}/_search`, {
|
|
176
|
+
method: "POST",
|
|
177
|
+
body,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
return result.aggregations.by_field.buckets.map((bucket) => ({
|
|
181
|
+
key: bucket.key,
|
|
182
|
+
count: bucket.doc_count,
|
|
183
|
+
}));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
formatLogs(logs: ESLogEntry[]): string {
|
|
187
|
+
const lines: string[] = [];
|
|
188
|
+
for (const log of logs) {
|
|
189
|
+
const level = log.level ? `[${log.level.toUpperCase()}]` : "";
|
|
190
|
+
const source = log.pod ? `${log.namespace}/${log.pod}` : "";
|
|
191
|
+
const prefix = [log.timestamp, level, source].filter(Boolean).join(" ");
|
|
192
|
+
lines.push(`${prefix}: ${log.message}`);
|
|
193
|
+
}
|
|
194
|
+
return lines.join("\n");
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Singleton instance
|
|
199
|
+
let esClient: ElasticsearchClient | null = null;
|
|
200
|
+
|
|
201
|
+
export function getElasticsearchClient(): ElasticsearchClient | null {
|
|
202
|
+
return esClient;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function initElasticsearchClient(config?: ElasticsearchConfig): ElasticsearchClient | null {
|
|
206
|
+
if (config) {
|
|
207
|
+
esClient = new ElasticsearchClient(config);
|
|
208
|
+
}
|
|
209
|
+
return esClient;
|
|
210
|
+
}
|