triagent 0.1.0-alpha9 → 0.1.0-beta2
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 +101 -1
- package/package.json +9 -3
- package/src/cli/config.ts +118 -2
- package/src/config.ts +23 -3
- package/src/index.ts +262 -6
- 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 +164 -109
- package/src/mastra/index.ts +2 -2
- 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/sandbox/bashlet.ts +76 -10
- 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 +278 -197
- package/src/tui/components/approval-dialog.tsx +147 -0
- package/src/tui/components/approval-modal.tsx +278 -0
- package/src/tui/components/centered-layout.tsx +33 -0
- package/src/tui/components/editor.tsx +87 -0
- package/src/tui/components/header.tsx +53 -0
- package/src/tui/components/index.ts +55 -0
- package/src/tui/components/message-item.tsx +131 -0
- package/src/tui/components/messages-panel.tsx +71 -0
- package/src/tui/components/status-badge.tsx +20 -0
- package/src/tui/components/status-bar.tsx +39 -0
- package/src/tui/components/styled-span.tsx +24 -0
- package/src/tui/components/timeline.tsx +223 -0
- package/src/tui/components/toast.tsx +104 -0
- package/src/tui/theme/index.ts +21 -0
- package/src/tui/theme/tokens.ts +180 -0
package/src/index.ts
CHANGED
|
@@ -16,14 +16,20 @@ 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;
|
|
26
31
|
host: boolean;
|
|
32
|
+
remote: string | null;
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
function parseArgs(): CliArgs {
|
|
@@ -34,6 +40,7 @@ function parseArgs(): CliArgs {
|
|
|
34
40
|
incident: null,
|
|
35
41
|
help: false,
|
|
36
42
|
host: false,
|
|
43
|
+
remote: null,
|
|
37
44
|
};
|
|
38
45
|
|
|
39
46
|
// Check for config subcommand
|
|
@@ -45,6 +52,30 @@ function parseArgs(): CliArgs {
|
|
|
45
52
|
return result;
|
|
46
53
|
}
|
|
47
54
|
|
|
55
|
+
// Check for cluster subcommand
|
|
56
|
+
if (args[0] === "cluster") {
|
|
57
|
+
result.command = "cluster";
|
|
58
|
+
result.clusterAction = args[1] as "add" | "remove" | "list" | "use" | "status";
|
|
59
|
+
|
|
60
|
+
// Parse cluster sub-command arguments
|
|
61
|
+
for (let i = 2; i < args.length; i++) {
|
|
62
|
+
const arg = args[i];
|
|
63
|
+
if (arg === "--name" || arg === "-n") {
|
|
64
|
+
result.clusterName = args[++i];
|
|
65
|
+
} else if (arg === "--context" || arg === "-c") {
|
|
66
|
+
result.clusterContext = args[++i];
|
|
67
|
+
} else if (arg === "--kubeconfig" || arg === "-k") {
|
|
68
|
+
result.clusterKubeConfig = args[++i];
|
|
69
|
+
} else if (arg === "--environment" || arg === "-e") {
|
|
70
|
+
result.clusterEnvironment = args[++i];
|
|
71
|
+
} else if (!arg.startsWith("-")) {
|
|
72
|
+
// Positional argument - cluster name
|
|
73
|
+
result.clusterName = arg;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
|
|
48
79
|
for (let i = 0; i < args.length; i++) {
|
|
49
80
|
const arg = args[i];
|
|
50
81
|
|
|
@@ -56,6 +87,8 @@ function parseArgs(): CliArgs {
|
|
|
56
87
|
result.help = true;
|
|
57
88
|
} else if (arg === "--host") {
|
|
58
89
|
result.host = true;
|
|
90
|
+
} else if (arg === "--remote" || arg === "-r") {
|
|
91
|
+
result.remote = args[++i] || null;
|
|
59
92
|
}
|
|
60
93
|
}
|
|
61
94
|
|
|
@@ -69,12 +102,14 @@ function printHelp(): void {
|
|
|
69
102
|
USAGE:
|
|
70
103
|
triagent [OPTIONS]
|
|
71
104
|
triagent config <action> [key] [value]
|
|
105
|
+
triagent cluster <action> [options]
|
|
72
106
|
|
|
73
107
|
OPTIONS:
|
|
74
108
|
-h, --help Show this help message
|
|
75
109
|
-w, --webhook-only Run only the webhook server (no TUI)
|
|
76
110
|
-i, --incident Direct incident input (runs once and exits)
|
|
77
111
|
--host Run commands on host machine (no sandbox)
|
|
112
|
+
-r, --remote Run commands on remote server via SSH (user@host)
|
|
78
113
|
|
|
79
114
|
CONFIG COMMANDS:
|
|
80
115
|
triagent config set <key> <value> Set a configuration value
|
|
@@ -88,9 +123,29 @@ CONFIG KEYS:
|
|
|
88
123
|
apiKey - API key for the provider
|
|
89
124
|
baseUrl - Custom API base URL (for proxies or local models)
|
|
90
125
|
webhookPort - Webhook server port (default: 3000)
|
|
91
|
-
codebasePath - Path to codebase (default: ./)
|
|
126
|
+
codebasePath - Path to codebase (default: ./) - for single codebase
|
|
92
127
|
kubeConfigPath - Kubernetes config path (default: ~/.kube)
|
|
93
128
|
|
|
129
|
+
For multiple codebases, edit ~/.config/triagent/config.json directly:
|
|
130
|
+
"codebasePaths": [
|
|
131
|
+
{ "name": "frontend", "path": "/path/to/frontend" },
|
|
132
|
+
{ "name": "backend", "path": "/path/to/backend" }
|
|
133
|
+
]
|
|
134
|
+
Each codebase will be mounted at /workspace/<name> in the sandbox.
|
|
135
|
+
|
|
136
|
+
CLUSTER COMMANDS:
|
|
137
|
+
triagent cluster add <name> --context <ctx> Add a cluster
|
|
138
|
+
triagent cluster remove <name> Remove a cluster
|
|
139
|
+
triagent cluster list List all clusters
|
|
140
|
+
triagent cluster use <name> Set active cluster
|
|
141
|
+
triagent cluster status [name] Check cluster status
|
|
142
|
+
|
|
143
|
+
CLUSTER OPTIONS:
|
|
144
|
+
-n, --name Cluster name
|
|
145
|
+
-c, --context Kubernetes context name
|
|
146
|
+
-k, --kubeconfig Path to kubeconfig file
|
|
147
|
+
-e, --environment Environment (development, staging, production)
|
|
148
|
+
|
|
94
149
|
MODES:
|
|
95
150
|
Interactive (default):
|
|
96
151
|
Run with no arguments to start the interactive TUI.
|
|
@@ -101,9 +156,12 @@ MODES:
|
|
|
101
156
|
incident webhooks from alerting systems.
|
|
102
157
|
|
|
103
158
|
Endpoints:
|
|
104
|
-
POST /webhook/incident
|
|
159
|
+
POST /webhook/incident - Submit an incident
|
|
105
160
|
GET /investigations/:id - Get investigation results
|
|
106
|
-
GET /
|
|
161
|
+
GET /history - List investigation history
|
|
162
|
+
GET /history/:id - Get investigation details
|
|
163
|
+
GET /history/stats - Get investigation statistics
|
|
164
|
+
GET /health - Health check
|
|
107
165
|
|
|
108
166
|
Direct Input:
|
|
109
167
|
Use --incident "description" for one-shot debugging.
|
|
@@ -130,6 +188,14 @@ EXAMPLES:
|
|
|
130
188
|
# Direct incident investigation
|
|
131
189
|
triagent -i "API gateway returning 503 errors"
|
|
132
190
|
|
|
191
|
+
# Run commands on a remote server via SSH
|
|
192
|
+
triagent --remote user@debug-container.local
|
|
193
|
+
|
|
194
|
+
# Multi-cluster management
|
|
195
|
+
triagent cluster add prod --context prod-cluster -e production
|
|
196
|
+
triagent cluster use prod
|
|
197
|
+
triagent cluster status
|
|
198
|
+
|
|
133
199
|
# Submit via curl (webhook mode)
|
|
134
200
|
curl -X POST http://localhost:3000/webhook/incident \\
|
|
135
201
|
-H "Content-Type: application/json" \\
|
|
@@ -178,6 +244,165 @@ async function runDirectIncident(description: string): Promise<void> {
|
|
|
178
244
|
}
|
|
179
245
|
}
|
|
180
246
|
|
|
247
|
+
import { initClusterManager, getClusterManager } from "./integrations/kubernetes/multi-cluster.js";
|
|
248
|
+
import type { ClusterConfig } from "./cli/config.js";
|
|
249
|
+
|
|
250
|
+
/** Parse remote target string: user@host[:port] */
|
|
251
|
+
function parseRemoteTarget(target: string): { user: string; host: string; port?: number } {
|
|
252
|
+
let user = "root";
|
|
253
|
+
let host = target;
|
|
254
|
+
let port: number | undefined;
|
|
255
|
+
|
|
256
|
+
if (target.includes("@")) {
|
|
257
|
+
[user, host] = target.split("@");
|
|
258
|
+
}
|
|
259
|
+
if (host.includes(":")) {
|
|
260
|
+
const parts = host.split(":");
|
|
261
|
+
host = parts[0];
|
|
262
|
+
port = parseInt(parts[1], 10);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return { user, host, port };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function handleClusterCommand(args: CliArgs): Promise<void> {
|
|
269
|
+
const config = await loadStoredConfig();
|
|
270
|
+
const clusterManager = initClusterManager(config.clusters, config.activeCluster);
|
|
271
|
+
|
|
272
|
+
switch (args.clusterAction) {
|
|
273
|
+
case "add": {
|
|
274
|
+
if (!args.clusterName) {
|
|
275
|
+
console.error("Usage: triagent cluster add <name> --context <context>");
|
|
276
|
+
process.exit(1);
|
|
277
|
+
}
|
|
278
|
+
const context = args.clusterContext || args.clusterName;
|
|
279
|
+
const newCluster: ClusterConfig = {
|
|
280
|
+
name: args.clusterName,
|
|
281
|
+
context,
|
|
282
|
+
kubeConfigPath: args.clusterKubeConfig,
|
|
283
|
+
environment: args.clusterEnvironment as ClusterConfig["environment"],
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
await clusterManager.addCluster(newCluster);
|
|
287
|
+
|
|
288
|
+
// Save to config
|
|
289
|
+
config.clusters = config.clusters || [];
|
|
290
|
+
config.clusters.push(newCluster);
|
|
291
|
+
await saveStoredConfig(config);
|
|
292
|
+
|
|
293
|
+
console.log(`✅ Added cluster: ${args.clusterName} (context: ${context})`);
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
case "remove": {
|
|
297
|
+
if (!args.clusterName) {
|
|
298
|
+
console.error("Usage: triagent cluster remove <name>");
|
|
299
|
+
process.exit(1);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const removed = await clusterManager.removeCluster(args.clusterName);
|
|
303
|
+
if (!removed) {
|
|
304
|
+
console.error(`Cluster not found: ${args.clusterName}`);
|
|
305
|
+
process.exit(1);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Remove from config
|
|
309
|
+
config.clusters = config.clusters?.filter((c) => c.name !== args.clusterName);
|
|
310
|
+
if (config.activeCluster === args.clusterName) {
|
|
311
|
+
config.activeCluster = undefined;
|
|
312
|
+
}
|
|
313
|
+
await saveStoredConfig(config);
|
|
314
|
+
|
|
315
|
+
console.log(`✅ Removed cluster: ${args.clusterName}`);
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
case "list": {
|
|
319
|
+
const clusters = clusterManager.listClusters();
|
|
320
|
+
if (clusters.length === 0) {
|
|
321
|
+
console.log("No clusters configured.");
|
|
322
|
+
console.log("\nDiscover available contexts:");
|
|
323
|
+
const discovered = await clusterManager.discoverClusters();
|
|
324
|
+
if (discovered.length > 0) {
|
|
325
|
+
console.log("\nAvailable Kubernetes contexts:");
|
|
326
|
+
for (const c of discovered) {
|
|
327
|
+
console.log(` - ${c.context} (${c.server})`);
|
|
328
|
+
}
|
|
329
|
+
console.log("\nAdd a cluster with: triagent cluster add <name> --context <context>");
|
|
330
|
+
} else {
|
|
331
|
+
console.log("No Kubernetes contexts found.");
|
|
332
|
+
}
|
|
333
|
+
} else {
|
|
334
|
+
console.log("Configured clusters:\n");
|
|
335
|
+
for (const c of clusters) {
|
|
336
|
+
const active = c.isActive ? " (active)" : "";
|
|
337
|
+
const env = c.environment ? ` [${c.environment}]` : "";
|
|
338
|
+
console.log(` ${c.name}${active}${env}`);
|
|
339
|
+
console.log(` context: ${c.context}`);
|
|
340
|
+
if (c.kubeConfigPath) {
|
|
341
|
+
console.log(` kubeconfig: ${c.kubeConfigPath}`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
case "use": {
|
|
348
|
+
if (!args.clusterName) {
|
|
349
|
+
console.error("Usage: triagent cluster use <name>");
|
|
350
|
+
process.exit(1);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const success = await clusterManager.setActiveCluster(args.clusterName);
|
|
354
|
+
if (!success) {
|
|
355
|
+
console.error(`Cluster not found: ${args.clusterName}`);
|
|
356
|
+
process.exit(1);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Save to config
|
|
360
|
+
config.activeCluster = args.clusterName;
|
|
361
|
+
await saveStoredConfig(config);
|
|
362
|
+
|
|
363
|
+
console.log(`✅ Active cluster set to: ${args.clusterName}`);
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
case "status": {
|
|
367
|
+
const clusterName = args.clusterName;
|
|
368
|
+
if (clusterName) {
|
|
369
|
+
const status = await clusterManager.checkClusterStatus(clusterName);
|
|
370
|
+
console.log(`Cluster: ${status.name}`);
|
|
371
|
+
console.log(` Connected: ${status.connected ? "✅ Yes" : "❌ No"}`);
|
|
372
|
+
if (status.connected) {
|
|
373
|
+
console.log(` Version: ${status.version}`);
|
|
374
|
+
console.log(` Nodes: ${status.nodeCount}`);
|
|
375
|
+
}
|
|
376
|
+
if (status.error) {
|
|
377
|
+
console.log(` Error: ${status.error}`);
|
|
378
|
+
}
|
|
379
|
+
} else {
|
|
380
|
+
const clusters = clusterManager.listClusters();
|
|
381
|
+
if (clusters.length === 0) {
|
|
382
|
+
console.log("No clusters configured.");
|
|
383
|
+
} else {
|
|
384
|
+
console.log("Cluster status:\n");
|
|
385
|
+
for (const c of clusters) {
|
|
386
|
+
const status = await clusterManager.checkClusterStatus(c.name);
|
|
387
|
+
const active = c.isActive ? " (active)" : "";
|
|
388
|
+
const connected = status.connected ? "✅" : "❌";
|
|
389
|
+
console.log(` ${connected} ${c.name}${active}`);
|
|
390
|
+
if (status.connected) {
|
|
391
|
+
console.log(` v${status.version}, ${status.nodeCount} nodes`);
|
|
392
|
+
} else if (status.error) {
|
|
393
|
+
console.log(` Error: ${status.error}`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
default:
|
|
401
|
+
console.error("Usage: triagent cluster <add|remove|list|use|status> [options]");
|
|
402
|
+
process.exit(1);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
181
406
|
async function handleConfigCommand(args: CliArgs): Promise<void> {
|
|
182
407
|
const validKeys: (keyof StoredConfig)[] = [
|
|
183
408
|
"aiProvider",
|
|
@@ -266,6 +491,12 @@ async function main(): Promise<void> {
|
|
|
266
491
|
process.exit(0);
|
|
267
492
|
}
|
|
268
493
|
|
|
494
|
+
// Handle cluster command
|
|
495
|
+
if (args.command === "cluster") {
|
|
496
|
+
await handleClusterCommand(args);
|
|
497
|
+
process.exit(0);
|
|
498
|
+
}
|
|
499
|
+
|
|
269
500
|
// Load configuration
|
|
270
501
|
let config;
|
|
271
502
|
try {
|
|
@@ -277,12 +508,37 @@ async function main(): Promise<void> {
|
|
|
277
508
|
process.exit(1);
|
|
278
509
|
}
|
|
279
510
|
|
|
511
|
+
// Validate mutually exclusive options
|
|
512
|
+
if (args.host && args.remote) {
|
|
513
|
+
console.error("❌ Cannot use --host and --remote together");
|
|
514
|
+
process.exit(1);
|
|
515
|
+
}
|
|
516
|
+
|
|
280
517
|
// Initialize sandbox and Mastra
|
|
281
518
|
try {
|
|
282
|
-
|
|
283
|
-
|
|
519
|
+
const sandboxOptions: {
|
|
520
|
+
useHost?: boolean;
|
|
521
|
+
backend?: "docker" | "ssh";
|
|
522
|
+
ssh?: { host: string; user: string; port?: number };
|
|
523
|
+
} = {};
|
|
524
|
+
if (args.host) {
|
|
525
|
+
sandboxOptions.useHost = true;
|
|
526
|
+
} else if (args.remote) {
|
|
527
|
+
const { user, host, port } = parseRemoteTarget(args.remote);
|
|
528
|
+
sandboxOptions.backend = "ssh";
|
|
529
|
+
sandboxOptions.ssh = { host, user, port };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
await initSandboxFromConfig(config, sandboxOptions);
|
|
533
|
+
await createMastraInstance(config);
|
|
534
|
+
|
|
284
535
|
if (args.host) {
|
|
285
536
|
console.log("⚠️ Running in host mode (no sandbox)\n");
|
|
537
|
+
} else if (args.remote) {
|
|
538
|
+
const { getRemoteInfo } = await import("./sandbox/bashlet.js");
|
|
539
|
+
const info = getRemoteInfo();
|
|
540
|
+
console.log(`🌐 Running in remote mode: ${args.remote}`);
|
|
541
|
+
console.log(` Workspace: ${info?.workdir} (session: ${info?.sessionId})\n`);
|
|
286
542
|
}
|
|
287
543
|
} catch (error) {
|
|
288
544
|
console.error("❌ Initialization error:", error);
|
|
@@ -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
|
+
}
|