@blogic-cz/agent-tools 0.10.0 → 0.11.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.
@@ -0,0 +1,72 @@
1
+ import { Console, Effect } from "effect";
2
+ import { Command } from "effect/unstable/cli";
3
+
4
+ import { formatOption, formatOutput } from "#shared";
5
+
6
+ import {
7
+ envOption,
8
+ formatGrafanaError,
9
+ grafanaFetch,
10
+ profileOption,
11
+ resolveConfig,
12
+ } from "./shared";
13
+
14
+ type Datasource = {
15
+ id: number;
16
+ uid: string;
17
+ name: string;
18
+ type: string;
19
+ url: string;
20
+ isDefault: boolean;
21
+ };
22
+
23
+ const listCommand = Command.make(
24
+ "list",
25
+ { format: formatOption, env: envOption, profile: profileOption },
26
+ ({ format, env, profile }) => {
27
+ const start = Date.now();
28
+
29
+ return Effect.gen(function* () {
30
+ const config = yield* resolveConfig(env, profile);
31
+
32
+ const items = yield* grafanaFetch<Datasource[]>(config, "/api/datasources");
33
+
34
+ const result = {
35
+ success: true,
36
+ message: `Found ${items.length} datasource(s)`,
37
+ data: {
38
+ datasources: items.map((item) => ({
39
+ uid: item.uid,
40
+ name: item.name,
41
+ type: item.type,
42
+ url: item.url,
43
+ isDefault: item.isDefault,
44
+ })),
45
+ count: items.length,
46
+ },
47
+ executionTimeMs: Date.now() - start,
48
+ };
49
+
50
+ yield* Console.log(formatOutput(result, format));
51
+ }).pipe(
52
+ Effect.catch((error) =>
53
+ Effect.gen(function* () {
54
+ const result = {
55
+ success: false,
56
+ message: "Failed to list datasources",
57
+ error: formatGrafanaError(error),
58
+ hint: "Check Grafana is running and accessible",
59
+ executionTimeMs: Date.now() - start,
60
+ };
61
+
62
+ yield* Console.log(formatOutput(result, format));
63
+ }),
64
+ ),
65
+ );
66
+ },
67
+ ).pipe(Command.withDescription("List configured datasources"));
68
+
69
+ export const datasourcesCommand = Command.make("datasources", {}).pipe(
70
+ Command.withDescription("Datasource operations"),
71
+ Command.withSubcommands([listCommand]),
72
+ );
@@ -0,0 +1,8 @@
1
+ import { Schema } from "effect";
2
+
3
+ export class GrafanaToolError extends Schema.TaggedErrorClass<GrafanaToolError>()(
4
+ "GrafanaToolError",
5
+ {
6
+ cause: Schema.Unknown,
7
+ },
8
+ ) {}
@@ -0,0 +1,57 @@
1
+ import { Console, Effect } from "effect";
2
+ import { Command } from "effect/unstable/cli";
3
+
4
+ import { formatOption, formatOutput } from "#shared";
5
+
6
+ import {
7
+ envOption,
8
+ formatGrafanaError,
9
+ grafanaFetch,
10
+ profileOption,
11
+ resolveConfig,
12
+ } from "./shared";
13
+
14
+ export const healthCommand = Command.make(
15
+ "health",
16
+ { format: formatOption, env: envOption, profile: profileOption },
17
+ ({ format, env, profile }) => {
18
+ const start = Date.now();
19
+
20
+ return Effect.gen(function* () {
21
+ const config = yield* resolveConfig(env, profile);
22
+
23
+ const body = yield* grafanaFetch<{ database?: string; version?: string; commit?: string }>(
24
+ config,
25
+ "/api/health",
26
+ );
27
+
28
+ const result = {
29
+ success: true,
30
+ message: `Grafana is healthy (${config.url})`,
31
+ data: {
32
+ url: config.url,
33
+ httpStatus: 200,
34
+ response: body,
35
+ env,
36
+ },
37
+ executionTimeMs: Date.now() - start,
38
+ };
39
+
40
+ yield* Console.log(formatOutput(result, format));
41
+ }).pipe(
42
+ Effect.catch((error) =>
43
+ Effect.gen(function* () {
44
+ const result = {
45
+ success: false,
46
+ message: "Grafana is unreachable",
47
+ error: formatGrafanaError(error),
48
+ hint: "Check Grafana is running and accessible",
49
+ executionTimeMs: Date.now() - start,
50
+ };
51
+
52
+ yield* Console.log(formatOutput(result, format));
53
+ }),
54
+ ),
55
+ );
56
+ },
57
+ ).pipe(Command.withDescription("Check Grafana health and connectivity"));
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { BunRuntime, BunServices } from "@effect/platform-bun";
4
+ import { Console, Effect, Layer } from "effect";
5
+ import type { Cause } from "effect";
6
+ import { Command } from "effect/unstable/cli";
7
+
8
+ import { ConfigServiceLayer } from "#config";
9
+ import { AuditServiceLayer, withAudit } from "#shared/audit";
10
+ import { VERSION } from "#shared";
11
+
12
+ import { alertsCommand } from "./alerts";
13
+ import { dashboardsCommand } from "./dashboards";
14
+ import { datasourcesCommand } from "./datasources";
15
+ import { healthCommand } from "./health";
16
+ import { logsCommand } from "./logs";
17
+ import { metricsCommand } from "./metrics";
18
+
19
+ const renderCauseToStderr = (cause: Cause.Cause<unknown>) => Console.error(cause.toString());
20
+
21
+ const mainCommand = Command.make("grafana-tool", {}).pipe(
22
+ Command.withDescription("Grafana queries — dashboards, alerts, Prometheus metrics, Loki logs"),
23
+ Command.withSubcommands([
24
+ healthCommand,
25
+ dashboardsCommand,
26
+ alertsCommand,
27
+ datasourcesCommand,
28
+ metricsCommand,
29
+ logsCommand,
30
+ ]),
31
+ );
32
+
33
+ const cli = Command.run(mainCommand, { version: VERSION });
34
+
35
+ const MainLayer = Layer.mergeAll(BunServices.layer, ConfigServiceLayer, AuditServiceLayer);
36
+
37
+ const program = withAudit("grafana", cli).pipe(
38
+ Effect.provide(MainLayer),
39
+ Effect.tapCause(renderCauseToStderr),
40
+ );
41
+
42
+ BunRuntime.runMain(program, { disableErrorReporting: true });
@@ -0,0 +1,124 @@
1
+ import { Console, Effect } from "effect";
2
+ import { Argument, Command, Flag } from "effect/unstable/cli";
3
+
4
+ import { formatOption, formatOutput } from "#shared";
5
+
6
+ import { GrafanaToolError } from "./errors";
7
+ import {
8
+ envOption,
9
+ formatGrafanaError,
10
+ grafanaDsQuery,
11
+ profileOption,
12
+ resolveConfig,
13
+ } from "./shared";
14
+
15
+ function parseLabel(value: string | Record<string, string>): Record<string, string> {
16
+ if (typeof value === "object" && value !== null) {
17
+ return value;
18
+ }
19
+
20
+ try {
21
+ return JSON.parse(value) as Record<string, string>;
22
+ } catch {
23
+ return {};
24
+ }
25
+ }
26
+
27
+ const queryCommand = Command.make(
28
+ "query",
29
+ {
30
+ logql: Argument.string("logql"),
31
+ format: formatOption,
32
+ env: envOption,
33
+ profile: profileOption,
34
+ limit: Flag.integer("limit").pipe(
35
+ Flag.withDescription("Max log lines (default: 100)"),
36
+ Flag.withDefault(100),
37
+ ),
38
+ start: Flag.string("start").pipe(
39
+ Flag.withDescription("Start time (default: now-1h)"),
40
+ Flag.withDefault("now-1h"),
41
+ ),
42
+ end: Flag.string("end").pipe(
43
+ Flag.withDescription("End time (default: now)"),
44
+ Flag.withDefault("now"),
45
+ ),
46
+ },
47
+ ({ logql, format, env, profile, limit, start, end }) => {
48
+ const startedAt = Date.now();
49
+
50
+ return Effect.gen(function* () {
51
+ const config = yield* resolveConfig(env, profile);
52
+ const response = yield* grafanaDsQuery(config, config.lokiUid, "loki", logql, {
53
+ from: start,
54
+ to: end,
55
+ maxLines: limit,
56
+ });
57
+
58
+ if (response.results.A.error) {
59
+ return yield* new GrafanaToolError({
60
+ cause: new Error(response.results.A.error),
61
+ });
62
+ }
63
+
64
+ const logs: Array<{ timestamp: string; line: string; labels: Record<string, string> }> = [];
65
+ for (const frame of response.results.A.frames ?? []) {
66
+ const fields = frame.schema.fields;
67
+ const values = frame.data.values;
68
+ const timeIndex = fields.findIndex(
69
+ (field) => field.name === "timestamp" || field.type === "time",
70
+ );
71
+ const lineIndex = fields.findIndex(
72
+ (field) => field.name === "body" || field.name === "Line" || field.name === "line",
73
+ );
74
+ const labelsIndex = fields.findIndex(
75
+ (field) => field.name === "labels" || field.name === "labelTypes",
76
+ );
77
+
78
+ const timestamps = (timeIndex >= 0 ? values[timeIndex] : []) as Array<string | number>;
79
+ const lines = (lineIndex >= 0 ? values[lineIndex] : []) as string[];
80
+ const labelValues = (labelsIndex >= 0 ? values[labelsIndex] : []) as Array<
81
+ string | Record<string, string>
82
+ >;
83
+
84
+ for (const [index, line] of lines.entries()) {
85
+ logs.push({
86
+ timestamp: String(timestamps[index] ?? ""),
87
+ line,
88
+ labels: labelValues[index] ? parseLabel(labelValues[index]) : {},
89
+ });
90
+ }
91
+ }
92
+
93
+ logs.sort((left, right) => (left.timestamp > right.timestamp ? -1 : 1));
94
+
95
+ const result = {
96
+ success: true,
97
+ message: `LogQL query returned ${logs.length} log line(s)`,
98
+ data: { logs, query: logql, logCount: logs.length },
99
+ executionTimeMs: Date.now() - startedAt,
100
+ };
101
+
102
+ yield* Console.log(formatOutput(result, format));
103
+ }).pipe(
104
+ Effect.catch((error) =>
105
+ Effect.gen(function* () {
106
+ const result = {
107
+ success: false,
108
+ message: "Failed to execute LogQL query",
109
+ error: formatGrafanaError(error),
110
+ hint: "Check LogQL syntax and Grafana/Loki connectivity",
111
+ executionTimeMs: Date.now() - startedAt,
112
+ };
113
+
114
+ yield* Console.log(formatOutput(result, format));
115
+ }),
116
+ ),
117
+ );
118
+ },
119
+ ).pipe(Command.withDescription("Query logs via Grafana/Loki"));
120
+
121
+ export const logsCommand = Command.make("logs", {}).pipe(
122
+ Command.withDescription("Loki log operations"),
123
+ Command.withSubcommands([queryCommand]),
124
+ );
@@ -0,0 +1,217 @@
1
+ import { Console, Effect } from "effect";
2
+ import { Argument, Command, Flag } from "effect/unstable/cli";
3
+
4
+ import { formatOption, formatOutput } from "#shared";
5
+
6
+ import { GrafanaToolError } from "./errors";
7
+ import {
8
+ envOption,
9
+ formatGrafanaError,
10
+ grafanaDsQuery,
11
+ profileOption,
12
+ resolveConfig,
13
+ } from "./shared";
14
+ import type { DsQueryResponse } from "./types";
15
+
16
+ function parsePrometheusFrames(frames: DsQueryResponse["results"]["A"]["frames"]) {
17
+ if (!frames || frames.length === 0) {
18
+ return [] as Array<{ metric: Record<string, string>; value: number; timestamp: number }>;
19
+ }
20
+
21
+ const results: Array<{ metric: Record<string, string>; value: number; timestamp: number }> = [];
22
+
23
+ for (const frame of frames) {
24
+ const fields = frame.schema.fields;
25
+ const values = frame.data.values;
26
+ const timeIndex = fields.findIndex((field) => field.type === "time");
27
+ const valueIndex = fields.findIndex((field) => field.type === "number");
28
+
29
+ if (timeIndex < 0 || valueIndex < 0) {
30
+ continue;
31
+ }
32
+
33
+ const labelFields = fields.filter((field) => field.type === "string");
34
+ const timestamps = values[timeIndex] as number[];
35
+ const seriesValues = values[valueIndex] as number[];
36
+
37
+ for (const [index, timestamp] of timestamps.entries()) {
38
+ const metric: Record<string, string> = {};
39
+ for (const labelField of labelFields) {
40
+ const labelIndex = fields.indexOf(labelField);
41
+ const labels = values[labelIndex] as string[];
42
+ if (labels[index]) {
43
+ metric[labelField.name] = labels[index];
44
+ }
45
+ }
46
+
47
+ results.push({
48
+ metric,
49
+ value: seriesValues[index] ?? 0,
50
+ timestamp,
51
+ });
52
+ }
53
+ }
54
+
55
+ return results;
56
+ }
57
+
58
+ function extractLabelsFromFrame(
59
+ frame: NonNullable<DsQueryResponse["results"]["A"]["frames"]>[number],
60
+ ) {
61
+ const labels: Record<string, string> = {};
62
+ for (const [index, field] of frame.schema.fields.entries()) {
63
+ if (field.type !== "string") {
64
+ continue;
65
+ }
66
+
67
+ const values = frame.data.values[index] as string[];
68
+ if (values[0]) {
69
+ labels[field.name] = values[0];
70
+ }
71
+ }
72
+ return labels;
73
+ }
74
+
75
+ const queryCommand = Command.make(
76
+ "query",
77
+ {
78
+ promql: Argument.string("promql"),
79
+ format: formatOption,
80
+ env: envOption,
81
+ profile: profileOption,
82
+ },
83
+ ({ promql, format, env, profile }) => {
84
+ const startedAt = Date.now();
85
+
86
+ return Effect.gen(function* () {
87
+ const config = yield* resolveConfig(env, profile);
88
+ const response = yield* grafanaDsQuery(config, config.prometheusUid, "prometheus", promql, {
89
+ instant: true,
90
+ maxDataPoints: 1,
91
+ });
92
+
93
+ if (response.results.A.error) {
94
+ return yield* new GrafanaToolError({
95
+ cause: new Error(response.results.A.error),
96
+ });
97
+ }
98
+
99
+ const parsed = parsePrometheusFrames(response.results.A.frames);
100
+ const result = {
101
+ success: true,
102
+ message: `PromQL query returned ${parsed.length} result(s)`,
103
+ data: {
104
+ results: parsed,
105
+ query: promql,
106
+ resultCount: parsed.length,
107
+ },
108
+ executionTimeMs: Date.now() - startedAt,
109
+ };
110
+
111
+ yield* Console.log(formatOutput(result, format));
112
+ }).pipe(
113
+ Effect.catch((error) =>
114
+ Effect.gen(function* () {
115
+ const result = {
116
+ success: false,
117
+ message: "Failed to execute PromQL query",
118
+ error: formatGrafanaError(error),
119
+ hint: "Check PromQL syntax and Grafana/Prometheus connectivity",
120
+ executionTimeMs: Date.now() - startedAt,
121
+ };
122
+ yield* Console.log(formatOutput(result, format));
123
+ }),
124
+ ),
125
+ );
126
+ },
127
+ ).pipe(Command.withDescription("Execute instant PromQL query via Grafana"));
128
+
129
+ const rangeCommand = Command.make(
130
+ "range",
131
+ {
132
+ promql: Argument.string("promql"),
133
+ format: formatOption,
134
+ env: envOption,
135
+ profile: profileOption,
136
+ start: Flag.string("start").pipe(
137
+ Flag.withDescription("Start time (ISO 8601 or relative, e.g. now-1h)"),
138
+ Flag.withDefault("now-1h"),
139
+ ),
140
+ end: Flag.string("end").pipe(
141
+ Flag.withDescription("End time (ISO 8601 or relative, e.g. now)"),
142
+ Flag.withDefault("now"),
143
+ ),
144
+ step: Flag.integer("step").pipe(
145
+ Flag.withDescription("Step in seconds (default: 60)"),
146
+ Flag.withDefault(60),
147
+ ),
148
+ },
149
+ ({ promql, format, env, profile, start, end, step }) => {
150
+ const startedAt = Date.now();
151
+
152
+ return Effect.gen(function* () {
153
+ const config = yield* resolveConfig(env, profile);
154
+ const response = yield* grafanaDsQuery(config, config.prometheusUid, "prometheus", promql, {
155
+ instant: false,
156
+ from: start,
157
+ to: end,
158
+ step,
159
+ });
160
+
161
+ if (response.results.A.error) {
162
+ return yield* new GrafanaToolError({
163
+ cause: new Error(response.results.A.error),
164
+ });
165
+ }
166
+
167
+ const series = (response.results.A.frames ?? []).map((frame) => {
168
+ const timeIndex = frame.schema.fields.findIndex((field) => field.type === "time");
169
+ const valueIndex = frame.schema.fields.findIndex((field) => field.type === "number");
170
+ const timestamps = (timeIndex >= 0 ? frame.data.values[timeIndex] : []) as number[];
171
+ const values = (valueIndex >= 0 ? frame.data.values[valueIndex] : []) as number[];
172
+
173
+ return {
174
+ labels: extractLabelsFromFrame(frame),
175
+ values: timestamps.map((timestamp, index) => ({
176
+ timestamp,
177
+ value: values[index] ?? 0,
178
+ })),
179
+ };
180
+ });
181
+
182
+ const result = {
183
+ success: true,
184
+ message: `PromQL range query returned ${series.length} series`,
185
+ data: {
186
+ series,
187
+ query: promql,
188
+ start,
189
+ end,
190
+ step,
191
+ seriesCount: series.length,
192
+ },
193
+ executionTimeMs: Date.now() - startedAt,
194
+ };
195
+
196
+ yield* Console.log(formatOutput(result, format));
197
+ }).pipe(
198
+ Effect.catch((error) =>
199
+ Effect.gen(function* () {
200
+ const result = {
201
+ success: false,
202
+ message: "Failed to execute PromQL range query",
203
+ error: formatGrafanaError(error),
204
+ hint: "Check PromQL syntax and Grafana/Prometheus connectivity",
205
+ executionTimeMs: Date.now() - startedAt,
206
+ };
207
+ yield* Console.log(formatOutput(result, format));
208
+ }),
209
+ ),
210
+ );
211
+ },
212
+ ).pipe(Command.withDescription("Execute range PromQL query via Grafana"));
213
+
214
+ export const metricsCommand = Command.make("metrics", {}).pipe(
215
+ Command.withDescription("Prometheus metric operations via Grafana"),
216
+ Command.withSubcommands([queryCommand, rangeCommand]),
217
+ );