triagent 0.1.0-alpha8 → 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 -198
- 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
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import type { GrafanaConfig } from "../../cli/config.js";
|
|
2
|
+
|
|
3
|
+
export interface GrafanaDashboard {
|
|
4
|
+
id: number;
|
|
5
|
+
uid: string;
|
|
6
|
+
title: string;
|
|
7
|
+
url: string;
|
|
8
|
+
tags: string[];
|
|
9
|
+
folderTitle?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface GrafanaAnnotation {
|
|
13
|
+
id: number;
|
|
14
|
+
dashboardId: number;
|
|
15
|
+
panelId: number;
|
|
16
|
+
time: number;
|
|
17
|
+
timeEnd?: number;
|
|
18
|
+
text: string;
|
|
19
|
+
tags: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface GrafanaAlert {
|
|
23
|
+
id: number;
|
|
24
|
+
uid: string;
|
|
25
|
+
title: string;
|
|
26
|
+
state: "alerting" | "pending" | "ok" | "paused" | "no_data";
|
|
27
|
+
labels: Record<string, string>;
|
|
28
|
+
annotations: Record<string, string>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class GrafanaClient {
|
|
32
|
+
private baseUrl: string;
|
|
33
|
+
private apiKey: string;
|
|
34
|
+
|
|
35
|
+
constructor(config: GrafanaConfig) {
|
|
36
|
+
this.baseUrl = config.url.replace(/\/$/, "");
|
|
37
|
+
this.apiKey = config.apiKey;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private async request<T>(
|
|
41
|
+
endpoint: string,
|
|
42
|
+
options?: RequestInit & { params?: Record<string, string> }
|
|
43
|
+
): Promise<T> {
|
|
44
|
+
const url = new URL(`${this.baseUrl}${endpoint}`);
|
|
45
|
+
if (options?.params) {
|
|
46
|
+
for (const [key, value] of Object.entries(options.params)) {
|
|
47
|
+
url.searchParams.set(key, value);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const headers: Record<string, string> = {
|
|
52
|
+
"Accept": "application/json",
|
|
53
|
+
"Content-Type": "application/json",
|
|
54
|
+
"Authorization": `Bearer ${this.apiKey}`,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const response = await fetch(url.toString(), {
|
|
58
|
+
...options,
|
|
59
|
+
headers: { ...headers, ...options?.headers },
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
if (!response.ok) {
|
|
63
|
+
throw new Error(`Grafana API error: ${response.status} ${response.statusText}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return response.json() as Promise<T>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async searchDashboards(query?: string, tags?: string[]): Promise<GrafanaDashboard[]> {
|
|
70
|
+
const params: Record<string, string> = { type: "dash-db" };
|
|
71
|
+
if (query) {
|
|
72
|
+
params.query = query;
|
|
73
|
+
}
|
|
74
|
+
if (tags && tags.length > 0) {
|
|
75
|
+
params.tag = tags.join(",");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return this.request<GrafanaDashboard[]>("/api/search", { params });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async getDashboard(uid: string): Promise<{
|
|
82
|
+
dashboard: Record<string, unknown>;
|
|
83
|
+
meta: Record<string, unknown>;
|
|
84
|
+
}> {
|
|
85
|
+
return this.request(`/api/dashboards/uid/${uid}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async getAnnotations(options?: {
|
|
89
|
+
from?: number;
|
|
90
|
+
to?: number;
|
|
91
|
+
dashboardId?: number;
|
|
92
|
+
tags?: string[];
|
|
93
|
+
}): Promise<GrafanaAnnotation[]> {
|
|
94
|
+
const params: Record<string, string> = {};
|
|
95
|
+
if (options?.from) {
|
|
96
|
+
params.from = String(options.from);
|
|
97
|
+
}
|
|
98
|
+
if (options?.to) {
|
|
99
|
+
params.to = String(options.to);
|
|
100
|
+
}
|
|
101
|
+
if (options?.dashboardId) {
|
|
102
|
+
params.dashboardId = String(options.dashboardId);
|
|
103
|
+
}
|
|
104
|
+
if (options?.tags && options.tags.length > 0) {
|
|
105
|
+
params.tags = options.tags.join(",");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return this.request<GrafanaAnnotation[]>("/api/annotations", { params });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async createAnnotation(annotation: {
|
|
112
|
+
dashboardId?: number;
|
|
113
|
+
panelId?: number;
|
|
114
|
+
time: number;
|
|
115
|
+
timeEnd?: number;
|
|
116
|
+
text: string;
|
|
117
|
+
tags?: string[];
|
|
118
|
+
}): Promise<{ id: number; message: string }> {
|
|
119
|
+
return this.request("/api/annotations", {
|
|
120
|
+
method: "POST",
|
|
121
|
+
body: JSON.stringify(annotation),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async getAlerts(): Promise<GrafanaAlert[]> {
|
|
126
|
+
return this.request<GrafanaAlert[]>("/api/alerting/alerts");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async getAlertRules(): Promise<{ rules: unknown[] }> {
|
|
130
|
+
return this.request("/api/ruler/grafana/api/v1/rules");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async getDatasources(): Promise<Array<{
|
|
134
|
+
id: number;
|
|
135
|
+
uid: string;
|
|
136
|
+
name: string;
|
|
137
|
+
type: string;
|
|
138
|
+
url: string;
|
|
139
|
+
}>> {
|
|
140
|
+
return this.request("/api/datasources");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async queryDatasource(
|
|
144
|
+
datasourceUid: string,
|
|
145
|
+
query: Record<string, unknown>
|
|
146
|
+
): Promise<unknown> {
|
|
147
|
+
return this.request("/api/ds/query", {
|
|
148
|
+
method: "POST",
|
|
149
|
+
body: JSON.stringify({
|
|
150
|
+
queries: [{ ...query, datasourceId: datasourceUid }],
|
|
151
|
+
}),
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
getDashboardUrl(uid: string): string {
|
|
156
|
+
return `${this.baseUrl}/d/${uid}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
getPanelUrl(dashboardUid: string, panelId: number, options?: {
|
|
160
|
+
from?: string;
|
|
161
|
+
to?: string;
|
|
162
|
+
}): string {
|
|
163
|
+
let url = `${this.baseUrl}/d/${dashboardUid}?viewPanel=${panelId}`;
|
|
164
|
+
if (options?.from) {
|
|
165
|
+
url += `&from=${options.from}`;
|
|
166
|
+
}
|
|
167
|
+
if (options?.to) {
|
|
168
|
+
url += `&to=${options.to}`;
|
|
169
|
+
}
|
|
170
|
+
return url;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Singleton instance
|
|
175
|
+
let grafanaClient: GrafanaClient | null = null;
|
|
176
|
+
|
|
177
|
+
export function getGrafanaClient(): GrafanaClient | null {
|
|
178
|
+
return grafanaClient;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function initGrafanaClient(config?: GrafanaConfig): GrafanaClient | null {
|
|
182
|
+
if (config) {
|
|
183
|
+
grafanaClient = new GrafanaClient(config);
|
|
184
|
+
}
|
|
185
|
+
return grafanaClient;
|
|
186
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { exec } from "child_process";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { readFile } from "fs/promises";
|
|
6
|
+
import type { KubernetesCluster, ClusterInfo, ClusterStatus } from "./types.js";
|
|
7
|
+
import type { ClusterConfig } from "../../cli/config.js";
|
|
8
|
+
|
|
9
|
+
const execAsync = promisify(exec);
|
|
10
|
+
|
|
11
|
+
export class MultiClusterManager {
|
|
12
|
+
private clusters: Map<string, KubernetesCluster> = new Map();
|
|
13
|
+
private activeCluster: string | null = null;
|
|
14
|
+
|
|
15
|
+
constructor(clusterConfigs?: ClusterConfig[], activeClusterName?: string) {
|
|
16
|
+
if (clusterConfigs) {
|
|
17
|
+
for (const config of clusterConfigs) {
|
|
18
|
+
this.clusters.set(config.name, {
|
|
19
|
+
...config,
|
|
20
|
+
isActive: config.name === activeClusterName,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
this.activeCluster = activeClusterName || null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async discoverClusters(): Promise<ClusterInfo[]> {
|
|
28
|
+
try {
|
|
29
|
+
const { stdout } = await execAsync("kubectl config get-contexts -o name");
|
|
30
|
+
const contexts = stdout.trim().split("\n").filter(Boolean);
|
|
31
|
+
|
|
32
|
+
const clusters: ClusterInfo[] = [];
|
|
33
|
+
for (const context of contexts) {
|
|
34
|
+
const info = await this.getClusterInfo(context);
|
|
35
|
+
if (info) {
|
|
36
|
+
clusters.push(info);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return clusters;
|
|
41
|
+
} catch {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private async getClusterInfo(context: string): Promise<ClusterInfo | null> {
|
|
47
|
+
try {
|
|
48
|
+
const { stdout } = await execAsync(
|
|
49
|
+
`kubectl config view -o jsonpath='{.contexts[?(@.name=="${context}")]}' --raw`
|
|
50
|
+
);
|
|
51
|
+
const contextData = JSON.parse(stdout || "{}");
|
|
52
|
+
|
|
53
|
+
const { stdout: clusterStdout } = await execAsync(
|
|
54
|
+
`kubectl config view -o jsonpath='{.clusters[?(@.name=="${contextData.context?.cluster}")].cluster.server}' --raw`
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
name: context,
|
|
59
|
+
context,
|
|
60
|
+
server: clusterStdout.trim(),
|
|
61
|
+
namespace: contextData.context?.namespace,
|
|
62
|
+
user: contextData.context?.user,
|
|
63
|
+
};
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async addCluster(config: ClusterConfig): Promise<void> {
|
|
70
|
+
const cluster: KubernetesCluster = {
|
|
71
|
+
...config,
|
|
72
|
+
isActive: false,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
this.clusters.set(config.name, cluster);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async removeCluster(name: string): Promise<boolean> {
|
|
79
|
+
if (this.activeCluster === name) {
|
|
80
|
+
this.activeCluster = null;
|
|
81
|
+
}
|
|
82
|
+
return this.clusters.delete(name);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async setActiveCluster(name: string): Promise<boolean> {
|
|
86
|
+
const cluster = this.clusters.get(name);
|
|
87
|
+
if (!cluster) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Deactivate all clusters
|
|
92
|
+
for (const c of this.clusters.values()) {
|
|
93
|
+
c.isActive = false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Activate the selected cluster
|
|
97
|
+
cluster.isActive = true;
|
|
98
|
+
this.activeCluster = name;
|
|
99
|
+
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
getActiveCluster(): KubernetesCluster | null {
|
|
104
|
+
if (!this.activeCluster) return null;
|
|
105
|
+
return this.clusters.get(this.activeCluster) || null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
listClusters(): KubernetesCluster[] {
|
|
109
|
+
return Array.from(this.clusters.values());
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async checkClusterStatus(name: string): Promise<ClusterStatus> {
|
|
113
|
+
const cluster = this.clusters.get(name);
|
|
114
|
+
if (!cluster) {
|
|
115
|
+
return {
|
|
116
|
+
name,
|
|
117
|
+
connected: false,
|
|
118
|
+
lastChecked: new Date(),
|
|
119
|
+
error: "Cluster not found",
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
const kubeConfigArg = cluster.kubeConfigPath
|
|
125
|
+
? `--kubeconfig="${cluster.kubeConfigPath}"`
|
|
126
|
+
: "";
|
|
127
|
+
const contextArg = `--context="${cluster.context}"`;
|
|
128
|
+
|
|
129
|
+
const { stdout: versionStdout } = await execAsync(
|
|
130
|
+
`kubectl ${kubeConfigArg} ${contextArg} version --short -o json`,
|
|
131
|
+
{ timeout: 10000 }
|
|
132
|
+
);
|
|
133
|
+
const version = JSON.parse(versionStdout);
|
|
134
|
+
|
|
135
|
+
const { stdout: nodesStdout } = await execAsync(
|
|
136
|
+
`kubectl ${kubeConfigArg} ${contextArg} get nodes -o json`,
|
|
137
|
+
{ timeout: 10000 }
|
|
138
|
+
);
|
|
139
|
+
const nodes = JSON.parse(nodesStdout);
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
name,
|
|
143
|
+
connected: true,
|
|
144
|
+
version: version.serverVersion?.gitVersion || "unknown",
|
|
145
|
+
nodeCount: nodes.items?.length || 0,
|
|
146
|
+
lastChecked: new Date(),
|
|
147
|
+
};
|
|
148
|
+
} catch (error) {
|
|
149
|
+
return {
|
|
150
|
+
name,
|
|
151
|
+
connected: false,
|
|
152
|
+
lastChecked: new Date(),
|
|
153
|
+
error: error instanceof Error ? error.message : String(error),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
getKubectlArgs(): string {
|
|
159
|
+
const cluster = this.getActiveCluster();
|
|
160
|
+
if (!cluster) return "";
|
|
161
|
+
|
|
162
|
+
const args: string[] = [];
|
|
163
|
+
if (cluster.kubeConfigPath) {
|
|
164
|
+
args.push(`--kubeconfig="${cluster.kubeConfigPath}"`);
|
|
165
|
+
}
|
|
166
|
+
args.push(`--context="${cluster.context}"`);
|
|
167
|
+
|
|
168
|
+
return args.join(" ");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
buildKubectlCommand(command: string): string {
|
|
172
|
+
const args = this.getKubectlArgs();
|
|
173
|
+
if (!args) return command;
|
|
174
|
+
|
|
175
|
+
// Insert cluster args after 'kubectl'
|
|
176
|
+
if (command.startsWith("kubectl ")) {
|
|
177
|
+
return `kubectl ${args} ${command.slice(8)}`;
|
|
178
|
+
}
|
|
179
|
+
return command;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Singleton instance
|
|
184
|
+
let clusterManager: MultiClusterManager | null = null;
|
|
185
|
+
|
|
186
|
+
export function getClusterManager(): MultiClusterManager {
|
|
187
|
+
if (!clusterManager) {
|
|
188
|
+
clusterManager = new MultiClusterManager();
|
|
189
|
+
}
|
|
190
|
+
return clusterManager;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function initClusterManager(
|
|
194
|
+
clusters?: ClusterConfig[],
|
|
195
|
+
activeCluster?: string
|
|
196
|
+
): MultiClusterManager {
|
|
197
|
+
clusterManager = new MultiClusterManager(clusters, activeCluster);
|
|
198
|
+
return clusterManager;
|
|
199
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface KubernetesCluster {
|
|
2
|
+
name: string;
|
|
3
|
+
context: string;
|
|
4
|
+
kubeConfigPath?: string;
|
|
5
|
+
environment?: "development" | "staging" | "production";
|
|
6
|
+
isActive: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ClusterInfo {
|
|
10
|
+
name: string;
|
|
11
|
+
context: string;
|
|
12
|
+
server: string;
|
|
13
|
+
namespace?: string;
|
|
14
|
+
user?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ClusterStatus {
|
|
18
|
+
name: string;
|
|
19
|
+
connected: boolean;
|
|
20
|
+
version?: string;
|
|
21
|
+
nodeCount?: number;
|
|
22
|
+
lastChecked: Date;
|
|
23
|
+
error?: string;
|
|
24
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import type { LokiConfig } from "../../cli/config.js";
|
|
2
|
+
|
|
3
|
+
export interface LokiQueryResult {
|
|
4
|
+
status: "success" | "error";
|
|
5
|
+
data: {
|
|
6
|
+
resultType: "streams" | "matrix" | "vector";
|
|
7
|
+
result: Array<{
|
|
8
|
+
stream: Record<string, string>;
|
|
9
|
+
values: Array<[string, string]>;
|
|
10
|
+
}>;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface LokiLogEntry {
|
|
15
|
+
timestamp: string;
|
|
16
|
+
message: string;
|
|
17
|
+
labels: Record<string, string>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class LokiClient {
|
|
21
|
+
private baseUrl: string;
|
|
22
|
+
|
|
23
|
+
constructor(config: LokiConfig) {
|
|
24
|
+
this.baseUrl = config.url.replace(/\/$/, "");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private async request<T>(
|
|
28
|
+
endpoint: string,
|
|
29
|
+
params?: Record<string, string>
|
|
30
|
+
): Promise<T> {
|
|
31
|
+
const url = new URL(`${this.baseUrl}${endpoint}`);
|
|
32
|
+
if (params) {
|
|
33
|
+
for (const [key, value] of Object.entries(params)) {
|
|
34
|
+
url.searchParams.set(key, value);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const response = await fetch(url.toString(), {
|
|
39
|
+
headers: { "Accept": "application/json" },
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
if (!response.ok) {
|
|
43
|
+
throw new Error(`Loki API error: ${response.status} ${response.statusText}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return response.json() as Promise<T>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async query(options: {
|
|
50
|
+
query: string;
|
|
51
|
+
limit?: number;
|
|
52
|
+
start?: string;
|
|
53
|
+
end?: string;
|
|
54
|
+
direction?: "forward" | "backward";
|
|
55
|
+
}): Promise<LokiLogEntry[]> {
|
|
56
|
+
const { query, limit = 100, start, end, direction = "backward" } = options;
|
|
57
|
+
|
|
58
|
+
const params: Record<string, string> = {
|
|
59
|
+
query,
|
|
60
|
+
limit: String(limit),
|
|
61
|
+
direction,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
if (start) {
|
|
65
|
+
params.start = this.toNanoTimestamp(start);
|
|
66
|
+
}
|
|
67
|
+
if (end) {
|
|
68
|
+
params.end = this.toNanoTimestamp(end);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const result = await this.request<LokiQueryResult>("/loki/api/v1/query_range", params);
|
|
72
|
+
|
|
73
|
+
const entries: LokiLogEntry[] = [];
|
|
74
|
+
for (const stream of result.data.result) {
|
|
75
|
+
for (const [timestamp, message] of stream.values) {
|
|
76
|
+
entries.push({
|
|
77
|
+
timestamp: this.fromNanoTimestamp(timestamp),
|
|
78
|
+
message,
|
|
79
|
+
labels: stream.stream,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Sort by timestamp
|
|
85
|
+
entries.sort((a, b) => {
|
|
86
|
+
if (direction === "backward") {
|
|
87
|
+
return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();
|
|
88
|
+
}
|
|
89
|
+
return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return entries;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async tail(options: {
|
|
96
|
+
query: string;
|
|
97
|
+
limit?: number;
|
|
98
|
+
delayFor?: number;
|
|
99
|
+
}): Promise<LokiLogEntry[]> {
|
|
100
|
+
const { query, limit = 100, delayFor = 0 } = options;
|
|
101
|
+
|
|
102
|
+
const params: Record<string, string> = {
|
|
103
|
+
query,
|
|
104
|
+
limit: String(limit),
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
if (delayFor > 0) {
|
|
108
|
+
params.delay_for = String(delayFor);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const result = await this.request<LokiQueryResult>("/loki/api/v1/tail", params);
|
|
112
|
+
|
|
113
|
+
const entries: LokiLogEntry[] = [];
|
|
114
|
+
for (const stream of result.data.result) {
|
|
115
|
+
for (const [timestamp, message] of stream.values) {
|
|
116
|
+
entries.push({
|
|
117
|
+
timestamp: this.fromNanoTimestamp(timestamp),
|
|
118
|
+
message,
|
|
119
|
+
labels: stream.stream,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return entries;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async getLabels(): Promise<string[]> {
|
|
128
|
+
const result = await this.request<{ status: string; data: string[] }>("/loki/api/v1/labels");
|
|
129
|
+
return result.data;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async getLabelValues(label: string): Promise<string[]> {
|
|
133
|
+
const result = await this.request<{ status: string; data: string[] }>(
|
|
134
|
+
`/loki/api/v1/label/${label}/values`
|
|
135
|
+
);
|
|
136
|
+
return result.data;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async getSeries(match: string[]): Promise<Array<Record<string, string>>> {
|
|
140
|
+
const url = new URL(`${this.baseUrl}/loki/api/v1/series`);
|
|
141
|
+
for (const m of match) {
|
|
142
|
+
url.searchParams.append("match[]", m);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const response = await fetch(url.toString(), {
|
|
146
|
+
headers: { "Accept": "application/json" },
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
if (!response.ok) {
|
|
150
|
+
throw new Error(`Loki API error: ${response.status} ${response.statusText}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const result = await response.json() as { status: string; data: Array<Record<string, string>> };
|
|
154
|
+
return result.data;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private toNanoTimestamp(time: string): string {
|
|
158
|
+
// If already a nanosecond timestamp
|
|
159
|
+
if (/^\d{19}$/.test(time)) {
|
|
160
|
+
return time;
|
|
161
|
+
}
|
|
162
|
+
// Convert ISO string or relative time to nanoseconds
|
|
163
|
+
const date = this.parseTime(time);
|
|
164
|
+
return String(date.getTime() * 1_000_000);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private fromNanoTimestamp(nano: string): string {
|
|
168
|
+
const ms = parseInt(nano, 10) / 1_000_000;
|
|
169
|
+
return new Date(ms).toISOString();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private parseTime(time: string): Date {
|
|
173
|
+
// Check for relative time like "1h", "30m", "2d"
|
|
174
|
+
const match = time.match(/^(\d+)([smhdw])$/);
|
|
175
|
+
if (match) {
|
|
176
|
+
const [, amount, unit] = match;
|
|
177
|
+
const now = new Date();
|
|
178
|
+
const ms = parseInt(amount, 10) * {
|
|
179
|
+
s: 1000,
|
|
180
|
+
m: 60 * 1000,
|
|
181
|
+
h: 60 * 60 * 1000,
|
|
182
|
+
d: 24 * 60 * 60 * 1000,
|
|
183
|
+
w: 7 * 24 * 60 * 60 * 1000,
|
|
184
|
+
}[unit as "s" | "m" | "h" | "d" | "w"]!;
|
|
185
|
+
return new Date(now.getTime() - ms);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return new Date(time);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
formatLogs(logs: LokiLogEntry[]): string {
|
|
192
|
+
const lines: string[] = [];
|
|
193
|
+
for (const log of logs) {
|
|
194
|
+
const pod = log.labels.pod || log.labels.instance || "";
|
|
195
|
+
const namespace = log.labels.namespace || "";
|
|
196
|
+
const level = log.labels.level || "";
|
|
197
|
+
|
|
198
|
+
const levelStr = level ? `[${level.toUpperCase()}]` : "";
|
|
199
|
+
const source = namespace && pod ? `${namespace}/${pod}` : (pod || namespace);
|
|
200
|
+
const prefix = [log.timestamp, levelStr, source].filter(Boolean).join(" ");
|
|
201
|
+
lines.push(`${prefix}: ${log.message}`);
|
|
202
|
+
}
|
|
203
|
+
return lines.join("\n");
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Singleton instance
|
|
208
|
+
let lokiClient: LokiClient | null = null;
|
|
209
|
+
|
|
210
|
+
export function getLokiClient(): LokiClient | null {
|
|
211
|
+
return lokiClient;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function initLokiClient(config?: LokiConfig): LokiClient | null {
|
|
215
|
+
if (config) {
|
|
216
|
+
lokiClient = new LokiClient(config);
|
|
217
|
+
}
|
|
218
|
+
return lokiClient;
|
|
219
|
+
}
|