@thotischner/observability-mcp 3.0.1 → 3.1.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/dist/analysis/history.d.ts +36 -2
- package/dist/analysis/history.js +60 -2
- package/dist/analysis/history.test.js +46 -0
- package/dist/auth/csrf.d.ts +6 -0
- package/dist/auth/csrf.js +4 -0
- package/dist/auth/csrf.test.js +22 -0
- package/dist/auth/lockout.d.ts +72 -0
- package/dist/auth/lockout.js +134 -0
- package/dist/auth/lockout.test.d.ts +1 -0
- package/dist/auth/lockout.test.js +133 -0
- package/dist/auth/middleware.d.ts +5 -0
- package/dist/auth/middleware.js +6 -1
- package/dist/auth/middleware.test.js +31 -0
- package/dist/auth/password-policy.d.ts +52 -0
- package/dist/auth/password-policy.js +125 -0
- package/dist/auth/password-policy.test.d.ts +1 -0
- package/dist/auth/password-policy.test.js +111 -0
- package/dist/auth/revocation.d.ts +93 -0
- package/dist/auth/revocation.js +193 -0
- package/dist/auth/revocation.test.d.ts +1 -0
- package/dist/auth/revocation.test.js +136 -0
- package/dist/auth/session.d.ts +7 -0
- package/dist/auth/session.js +6 -0
- package/dist/auth/session.test.js +21 -0
- package/dist/connectors/interface.d.ts +5 -1
- package/dist/connectors/loki.d.ts +45 -1
- package/dist/connectors/loki.js +141 -8
- package/dist/connectors/loki.test.js +171 -1
- package/dist/index.js +217 -3
- package/dist/openapi.js +39 -0
- package/dist/openapi.test.js +1 -0
- package/dist/security/csp.d.ts +64 -0
- package/dist/security/csp.js +135 -0
- package/dist/security/csp.test.d.ts +1 -0
- package/dist/security/csp.test.js +97 -0
- package/dist/tools/query-logs.d.ts +40 -0
- package/dist/tools/query-logs.js +69 -3
- package/dist/tools/validation.d.ts +13 -0
- package/dist/tools/validation.js +74 -0
- package/dist/tools/validation.test.js +54 -1
- package/dist/types.d.ts +48 -0
- package/dist/ui/index.html +42 -15
- package/package.json +1 -1
|
@@ -22,10 +22,43 @@ export declare const queryLogsDefinition: {
|
|
|
22
22
|
type: string;
|
|
23
23
|
description: string;
|
|
24
24
|
};
|
|
25
|
+
labels: {
|
|
26
|
+
type: string;
|
|
27
|
+
additionalProperties: {
|
|
28
|
+
type: string;
|
|
29
|
+
};
|
|
30
|
+
description: string;
|
|
31
|
+
};
|
|
25
32
|
limit: {
|
|
26
33
|
type: string;
|
|
27
34
|
description: string;
|
|
28
35
|
};
|
|
36
|
+
aggregate: {
|
|
37
|
+
type: string;
|
|
38
|
+
description: string;
|
|
39
|
+
properties: {
|
|
40
|
+
op: {
|
|
41
|
+
type: string;
|
|
42
|
+
enum: string[];
|
|
43
|
+
};
|
|
44
|
+
by: {
|
|
45
|
+
type: string;
|
|
46
|
+
items: {
|
|
47
|
+
type: string;
|
|
48
|
+
};
|
|
49
|
+
description: string;
|
|
50
|
+
};
|
|
51
|
+
k: {
|
|
52
|
+
type: string;
|
|
53
|
+
description: string;
|
|
54
|
+
};
|
|
55
|
+
step: {
|
|
56
|
+
type: string;
|
|
57
|
+
description: string;
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
required: string[];
|
|
61
|
+
};
|
|
29
62
|
};
|
|
30
63
|
required: string[];
|
|
31
64
|
};
|
|
@@ -36,6 +69,13 @@ export declare function queryLogsHandler(registry: ConnectorRegistry, args: {
|
|
|
36
69
|
duration?: string;
|
|
37
70
|
level?: string;
|
|
38
71
|
limit?: number;
|
|
72
|
+
labels?: Record<string, string>;
|
|
73
|
+
aggregate?: {
|
|
74
|
+
op: "count_over_time" | "sum" | "topk";
|
|
75
|
+
by?: string[];
|
|
76
|
+
k?: number;
|
|
77
|
+
step?: string;
|
|
78
|
+
};
|
|
39
79
|
}, ctx?: RequestContext): Promise<{
|
|
40
80
|
content: {
|
|
41
81
|
type: "text";
|
package/dist/tools/query-logs.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { defaultContext } from "../context.js";
|
|
2
|
-
import { validateDuration, validateServiceName, errorResponse } from "./validation.js";
|
|
2
|
+
import { validateDuration, validateServiceName, validateLogLabels, validateLogAggregate, errorResponse } from "./validation.js";
|
|
3
3
|
export const queryLogsDefinition = {
|
|
4
4
|
name: "query_logs",
|
|
5
|
-
description: "Query logs for a service over a given timeframe. Returns log entries with a summary including error/warning counts and top error patterns.
|
|
5
|
+
description: "Query logs for a service over a given timeframe. Returns log entries with a summary including error/warning counts and top error patterns. Filter by log level, a free-text/regex search, OR structured `labels` (exact-match on backend-extracted fields like method/status/url/environment — far more reliable than regex on structured JSON logs).",
|
|
6
6
|
inputSchema: {
|
|
7
7
|
type: "object",
|
|
8
8
|
properties: {
|
|
@@ -22,9 +22,25 @@ export const queryLogsDefinition = {
|
|
|
22
22
|
type: "string",
|
|
23
23
|
description: "Filter by log level: 'error', 'warn', 'info', 'debug'",
|
|
24
24
|
},
|
|
25
|
+
labels: {
|
|
26
|
+
type: "object",
|
|
27
|
+
additionalProperties: { type: "string" },
|
|
28
|
+
description: "Structured equality filters on backend-extracted fields, AND'd together, e.g. {\"method\":\"GET\",\"url\":\"/\",\"status\":\"200\",\"environment\":\"prod\"}. Prefer this over `query` for structured JSON logs — the literal text rarely appears verbatim. Label names must be [a-zA-Z_][a-zA-Z0-9_]* (max 20).",
|
|
29
|
+
},
|
|
25
30
|
limit: {
|
|
26
31
|
type: "number",
|
|
27
|
-
description: "Maximum number of log entries to return. Default: 100",
|
|
32
|
+
description: "Maximum number of log entries to return. Default: 100. Ignored when `aggregate` is set.",
|
|
33
|
+
},
|
|
34
|
+
aggregate: {
|
|
35
|
+
type: "object",
|
|
36
|
+
description: "Server-side aggregation — returns grouped counts, not raw rows, so you get a number instead of a haystack. op: 'count_over_time' (time series of counts per bucket), 'sum' (total per group over the window), 'topk' (top-k groups by total). Example: {\"op\":\"topk\",\"by\":[\"url\"],\"k\":10} for the busiest paths. Honours `labels`/`query` filters.",
|
|
37
|
+
properties: {
|
|
38
|
+
op: { type: "string", enum: ["count_over_time", "sum", "topk"] },
|
|
39
|
+
by: { type: "array", items: { type: "string" }, description: "Group-by label names (required for topk)." },
|
|
40
|
+
k: { type: "number", description: "Top-k count (default 10)." },
|
|
41
|
+
step: { type: "string", description: "Bucket size for count_over_time, e.g. '15m'. Defaults to ~1/60th of the window." },
|
|
42
|
+
},
|
|
43
|
+
required: ["op"],
|
|
28
44
|
},
|
|
29
45
|
},
|
|
30
46
|
required: ["service"],
|
|
@@ -38,6 +54,12 @@ export async function queryLogsHandler(registry, args, ctx = defaultContext()) {
|
|
|
38
54
|
const durationErr = validateDuration(duration);
|
|
39
55
|
if (durationErr)
|
|
40
56
|
return errorResponse(durationErr);
|
|
57
|
+
const labelsErr = validateLogLabels(args.labels);
|
|
58
|
+
if (labelsErr)
|
|
59
|
+
return errorResponse(labelsErr);
|
|
60
|
+
const aggErr = validateLogAggregate(args.aggregate);
|
|
61
|
+
if (aggErr)
|
|
62
|
+
return errorResponse(aggErr);
|
|
41
63
|
const connectors = registry.getByTenant(ctx.tenant).filter((c) => c.signalType === "logs");
|
|
42
64
|
if (connectors.length === 0) {
|
|
43
65
|
return {
|
|
@@ -47,6 +69,49 @@ export async function queryLogsHandler(registry, args, ctx = defaultContext()) {
|
|
|
47
69
|
isError: true,
|
|
48
70
|
};
|
|
49
71
|
}
|
|
72
|
+
// Aggregate mode (Q-LOG2): route to the connector's queryLogAggregate.
|
|
73
|
+
if (args.aggregate) {
|
|
74
|
+
const aggResults = [];
|
|
75
|
+
const aggErrors = [];
|
|
76
|
+
let capable = 0;
|
|
77
|
+
for (const connector of connectors) {
|
|
78
|
+
if (!connector.queryLogAggregate)
|
|
79
|
+
continue;
|
|
80
|
+
capable++;
|
|
81
|
+
try {
|
|
82
|
+
const q = {
|
|
83
|
+
service: args.service,
|
|
84
|
+
duration,
|
|
85
|
+
labels: args.labels,
|
|
86
|
+
query: args.query,
|
|
87
|
+
op: args.aggregate.op,
|
|
88
|
+
by: args.aggregate.by,
|
|
89
|
+
k: args.aggregate.k,
|
|
90
|
+
step: args.aggregate.step,
|
|
91
|
+
};
|
|
92
|
+
aggResults.push(await connector.queryLogAggregate(q));
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
96
|
+
console.error(`Log aggregate failed on ${connector.name}:`, msg);
|
|
97
|
+
aggErrors.push(`${connector.name}: ${msg}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (capable === 0) {
|
|
101
|
+
return errorResponse("No log backend supports aggregation (queryLogAggregate).");
|
|
102
|
+
}
|
|
103
|
+
if (aggResults.length === 0) {
|
|
104
|
+
return {
|
|
105
|
+
content: [{ type: "text", text: JSON.stringify({ error: aggErrors.length ? `Aggregate failed: ${aggErrors.join("; ")}` : "No data returned", service: args.service, duration }) }],
|
|
106
|
+
isError: aggErrors.length > 0,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
content: [
|
|
111
|
+
{ type: "text", text: JSON.stringify(aggResults.length === 1 ? aggResults[0] : aggResults, null, 2) },
|
|
112
|
+
],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
50
115
|
const results = [];
|
|
51
116
|
const errors = [];
|
|
52
117
|
for (const connector of connectors) {
|
|
@@ -59,6 +124,7 @@ export async function queryLogsHandler(registry, args, ctx = defaultContext()) {
|
|
|
59
124
|
duration,
|
|
60
125
|
level: args.level,
|
|
61
126
|
limit: args.limit,
|
|
127
|
+
labels: args.labels,
|
|
62
128
|
});
|
|
63
129
|
results.push(result);
|
|
64
130
|
}
|
|
@@ -8,6 +8,19 @@ export declare function validateMetricName(metric: string, registry: ConnectorRe
|
|
|
8
8
|
*/
|
|
9
9
|
export declare function sanitizeLabelValue(value: string): string | null;
|
|
10
10
|
export declare function validateServiceName(service: string): string | null;
|
|
11
|
+
/**
|
|
12
|
+
* Validate a structured `labels` filter map for query_logs. Fail-closed:
|
|
13
|
+
* any bad key/value rejects the whole request rather than silently
|
|
14
|
+
* dropping a filter (a dropped filter could widen results past what the
|
|
15
|
+
* caller intended). Bounds the map size + value length so a crafted input
|
|
16
|
+
* can't build a pathological query.
|
|
17
|
+
*/
|
|
18
|
+
export declare function validateLogLabels(labels: unknown): string | null;
|
|
19
|
+
/**
|
|
20
|
+
* Validate the query_logs `aggregate` spec. Fail-closed, like the labels
|
|
21
|
+
* validator. Returns an error string or null.
|
|
22
|
+
*/
|
|
23
|
+
export declare function validateLogAggregate(aggregate: unknown): string | null;
|
|
11
24
|
export declare function errorResponse(message: string): {
|
|
12
25
|
content: {
|
|
13
26
|
type: "text";
|
package/dist/tools/validation.js
CHANGED
|
@@ -41,6 +41,80 @@ export function validateServiceName(service) {
|
|
|
41
41
|
}
|
|
42
42
|
return null;
|
|
43
43
|
}
|
|
44
|
+
/** A Prometheus/Loki label name: letter/underscore, then word chars. */
|
|
45
|
+
const LABEL_NAME_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
46
|
+
/**
|
|
47
|
+
* Validate a structured `labels` filter map for query_logs. Fail-closed:
|
|
48
|
+
* any bad key/value rejects the whole request rather than silently
|
|
49
|
+
* dropping a filter (a dropped filter could widen results past what the
|
|
50
|
+
* caller intended). Bounds the map size + value length so a crafted input
|
|
51
|
+
* can't build a pathological query.
|
|
52
|
+
*/
|
|
53
|
+
export function validateLogLabels(labels) {
|
|
54
|
+
if (labels === undefined)
|
|
55
|
+
return null;
|
|
56
|
+
if (typeof labels !== "object" || labels === null || Array.isArray(labels)) {
|
|
57
|
+
return "Invalid labels: must be an object mapping label names to string values.";
|
|
58
|
+
}
|
|
59
|
+
const entries = Object.entries(labels);
|
|
60
|
+
if (entries.length > 20) {
|
|
61
|
+
return "Too many labels (max 20).";
|
|
62
|
+
}
|
|
63
|
+
for (const [k, v] of entries) {
|
|
64
|
+
if (!LABEL_NAME_RE.test(k)) {
|
|
65
|
+
return `Invalid label name "${k}". Must match [a-zA-Z_][a-zA-Z0-9_]* (no dots, dashes, or quotes).`;
|
|
66
|
+
}
|
|
67
|
+
if (typeof v !== "string") {
|
|
68
|
+
return `Invalid value for label "${k}": must be a string.`;
|
|
69
|
+
}
|
|
70
|
+
if (v.length > 1024) {
|
|
71
|
+
return `Value for label "${k}" too long (max 1024 chars).`;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const AGGREGATE_OPS = new Set(["count_over_time", "sum", "topk"]);
|
|
77
|
+
/**
|
|
78
|
+
* Validate the query_logs `aggregate` spec. Fail-closed, like the labels
|
|
79
|
+
* validator. Returns an error string or null.
|
|
80
|
+
*/
|
|
81
|
+
export function validateLogAggregate(aggregate) {
|
|
82
|
+
if (aggregate === undefined)
|
|
83
|
+
return null;
|
|
84
|
+
if (typeof aggregate !== "object" || aggregate === null || Array.isArray(aggregate)) {
|
|
85
|
+
return "Invalid aggregate: must be an object with an `op`.";
|
|
86
|
+
}
|
|
87
|
+
const a = aggregate;
|
|
88
|
+
if (typeof a.op !== "string" || !AGGREGATE_OPS.has(a.op)) {
|
|
89
|
+
return `Invalid aggregate.op. Must be one of: ${[...AGGREGATE_OPS].join(", ")}.`;
|
|
90
|
+
}
|
|
91
|
+
if (a.by !== undefined) {
|
|
92
|
+
if (!Array.isArray(a.by) || !a.by.every((x) => typeof x === "string")) {
|
|
93
|
+
return "aggregate.by must be an array of label-name strings.";
|
|
94
|
+
}
|
|
95
|
+
if (a.by.length > 10)
|
|
96
|
+
return "aggregate.by has too many labels (max 10).";
|
|
97
|
+
for (const name of a.by) {
|
|
98
|
+
if (!LABEL_NAME_RE.test(name)) {
|
|
99
|
+
return `Invalid aggregate.by label "${name}". Must match [a-zA-Z_][a-zA-Z0-9_]*.`;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (a.k !== undefined) {
|
|
104
|
+
if (typeof a.k !== "number" || !Number.isFinite(a.k) || a.k <= 0 || a.k > 1000) {
|
|
105
|
+
return "aggregate.k must be a positive integer (max 1000).";
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (a.step !== undefined) {
|
|
109
|
+
if (typeof a.step !== "string" || validateDuration(a.step)) {
|
|
110
|
+
return "aggregate.step must be a duration like '15m', '1h'.";
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (a.op === "topk" && (a.by === undefined || a.by.length === 0)) {
|
|
114
|
+
return "aggregate.op 'topk' requires at least one `by` label to rank.";
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
44
118
|
export function errorResponse(message) {
|
|
45
119
|
return {
|
|
46
120
|
content: [{ type: "text", text: JSON.stringify({ error: message }) }],
|
|
@@ -1,6 +1,59 @@
|
|
|
1
1
|
import { describe, it } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { validateDuration, validateServiceName, sanitizeLabelValue, errorResponse } from "./validation.js";
|
|
3
|
+
import { validateDuration, validateServiceName, sanitizeLabelValue, validateLogLabels, validateLogAggregate, errorResponse } from "./validation.js";
|
|
4
|
+
describe("validateLogAggregate (Q-LOG2)", () => {
|
|
5
|
+
it("accepts undefined and valid specs", () => {
|
|
6
|
+
assert.equal(validateLogAggregate(undefined), null);
|
|
7
|
+
assert.equal(validateLogAggregate({ op: "count_over_time" }), null);
|
|
8
|
+
assert.equal(validateLogAggregate({ op: "sum", by: ["url", "status"] }), null);
|
|
9
|
+
assert.equal(validateLogAggregate({ op: "topk", by: ["url"], k: 5, step: "15m" }), null);
|
|
10
|
+
});
|
|
11
|
+
it("rejects a bad/missing op", () => {
|
|
12
|
+
assert.ok(validateLogAggregate({}));
|
|
13
|
+
assert.ok(validateLogAggregate({ op: "median" }));
|
|
14
|
+
assert.ok(validateLogAggregate("nope"));
|
|
15
|
+
});
|
|
16
|
+
it("rejects bad by labels", () => {
|
|
17
|
+
assert.ok(validateLogAggregate({ op: "sum", by: ["a.b"] }));
|
|
18
|
+
assert.ok(validateLogAggregate({ op: "sum", by: "url" }));
|
|
19
|
+
});
|
|
20
|
+
it("rejects bad k and step", () => {
|
|
21
|
+
assert.ok(validateLogAggregate({ op: "topk", by: ["url"], k: 0 }));
|
|
22
|
+
assert.ok(validateLogAggregate({ op: "topk", by: ["url"], k: 99999 }));
|
|
23
|
+
assert.ok(validateLogAggregate({ op: "count_over_time", step: "soon" }));
|
|
24
|
+
});
|
|
25
|
+
it("requires a by label for topk", () => {
|
|
26
|
+
assert.ok(validateLogAggregate({ op: "topk" }));
|
|
27
|
+
assert.ok(validateLogAggregate({ op: "topk", by: [] }));
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
describe("validateLogLabels (Q-LOG1)", () => {
|
|
31
|
+
it("accepts undefined (no filter) and a valid map", () => {
|
|
32
|
+
assert.equal(validateLogLabels(undefined), null);
|
|
33
|
+
assert.equal(validateLogLabels({ method: "GET", status: "200", environment: "prod" }), null);
|
|
34
|
+
});
|
|
35
|
+
it("rejects non-object inputs", () => {
|
|
36
|
+
assert.ok(validateLogLabels("nope"));
|
|
37
|
+
assert.ok(validateLogLabels(["a"]));
|
|
38
|
+
assert.ok(validateLogLabels(42));
|
|
39
|
+
});
|
|
40
|
+
it("rejects label names with dots, dashes, or quotes (injection-safe, fail-closed)", () => {
|
|
41
|
+
assert.ok(validateLogLabels({ "a.b": "x" }));
|
|
42
|
+
assert.ok(validateLogLabels({ "a-b": "x" }));
|
|
43
|
+
assert.ok(validateLogLabels({ 'a"x': "y" }));
|
|
44
|
+
assert.ok(validateLogLabels({ "1abc": "x" })); // can't start with a digit
|
|
45
|
+
});
|
|
46
|
+
it("rejects non-string and over-long values", () => {
|
|
47
|
+
assert.ok(validateLogLabels({ method: 123 }));
|
|
48
|
+
assert.ok(validateLogLabels({ method: "x".repeat(1025) }));
|
|
49
|
+
});
|
|
50
|
+
it("rejects too many labels", () => {
|
|
51
|
+
const many = {};
|
|
52
|
+
for (let i = 0; i < 21; i++)
|
|
53
|
+
many[`k${i}`] = "v";
|
|
54
|
+
assert.ok(validateLogLabels(many));
|
|
55
|
+
});
|
|
56
|
+
});
|
|
4
57
|
describe("validateDuration", () => {
|
|
5
58
|
it("accepts valid durations", () => {
|
|
6
59
|
assert.equal(validateDuration("5m"), null);
|
package/dist/types.d.ts
CHANGED
|
@@ -109,6 +109,54 @@ export interface LogQuery {
|
|
|
109
109
|
duration: string;
|
|
110
110
|
limit?: number;
|
|
111
111
|
level?: string;
|
|
112
|
+
/** Structured label/field equality filters, AND'd together. For Loki
|
|
113
|
+
* these compile to LogQL label-filter expressions after `| json`, so
|
|
114
|
+
* fields the backend already extracts (method, status, url, ip,
|
|
115
|
+
* environment, …) become first-class selectors instead of brittle
|
|
116
|
+
* free-text regex. */
|
|
117
|
+
labels?: Record<string, string>;
|
|
118
|
+
}
|
|
119
|
+
/** Server-side log aggregation (Q-LOG2). Pushes count/group/topk down to
|
|
120
|
+
* the backend's metric-query path so an agent gets a *number*, not a
|
|
121
|
+
* *haystack*. */
|
|
122
|
+
export interface LogAggregateQuery {
|
|
123
|
+
service: string;
|
|
124
|
+
duration: string;
|
|
125
|
+
/** Same structured filters as LogQuery, applied before aggregation. */
|
|
126
|
+
labels?: Record<string, string>;
|
|
127
|
+
/** Optional line filter applied before aggregation. */
|
|
128
|
+
query?: string;
|
|
129
|
+
/** count_over_time → time series of counts per bucket; sum → total per
|
|
130
|
+
* group over the window; topk → the top-k groups by total. */
|
|
131
|
+
op: "count_over_time" | "sum" | "topk";
|
|
132
|
+
/** Group-by label names. */
|
|
133
|
+
by?: string[];
|
|
134
|
+
/** k for topk (default 10). */
|
|
135
|
+
k?: number;
|
|
136
|
+
/** Bucket size for count_over_time, e.g. "15m". Defaults to the window. */
|
|
137
|
+
step?: string;
|
|
138
|
+
}
|
|
139
|
+
export interface LogAggregateSeries {
|
|
140
|
+
/** The group key (the `by` label values). Empty object for an ungrouped total. */
|
|
141
|
+
labels: Record<string, string>;
|
|
142
|
+
/** Single value — present for instant ops (sum / topk). */
|
|
143
|
+
value?: number;
|
|
144
|
+
/** Time series — present for count_over_time. */
|
|
145
|
+
points?: Array<{
|
|
146
|
+
t: number;
|
|
147
|
+
value: number;
|
|
148
|
+
}>;
|
|
149
|
+
}
|
|
150
|
+
export interface LogAggregateResult {
|
|
151
|
+
source: string;
|
|
152
|
+
op: string;
|
|
153
|
+
by: string[];
|
|
154
|
+
step?: string;
|
|
155
|
+
/** "instant" (vector) for sum/topk, "range" (matrix) for count_over_time. */
|
|
156
|
+
mode: "instant" | "range";
|
|
157
|
+
series: LogAggregateSeries[];
|
|
158
|
+
/** Operator-facing notes, e.g. that `limit` is ignored in aggregate mode. */
|
|
159
|
+
note?: string;
|
|
112
160
|
}
|
|
113
161
|
export interface DataPoint {
|
|
114
162
|
timestamp: string;
|
package/dist/ui/index.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
6
|
<title>Observability MCP Gateway</title>
|
|
7
|
-
<script>
|
|
7
|
+
<script nonce="__CSP_NONCE__">
|
|
8
8
|
// Resolve the theme + density BEFORE first paint to avoid a flash.
|
|
9
9
|
// Explicit user choice (localStorage) wins; otherwise follow the OS
|
|
10
10
|
// setting for theme and default to comfortable density.
|
|
@@ -2656,7 +2656,7 @@ curl -s http://localhost:3000/api/enterprise/status</pre>
|
|
|
2656
2656
|
<div class="drawer-bd" id="drawer-body"></div>
|
|
2657
2657
|
</aside>
|
|
2658
2658
|
|
|
2659
|
-
<script>
|
|
2659
|
+
<script nonce="__CSP_NONCE__">
|
|
2660
2660
|
let sourcesData=[], servicesData=[], supportedTypes=[], settings={}, healthThresholds={}, defaults={};
|
|
2661
2661
|
let deleteTarget=null, deleteType=null;
|
|
2662
2662
|
// Per-source metrics state
|
|
@@ -6251,9 +6251,8 @@ function saveMetric() {
|
|
|
6251
6251
|
// --- Health Dashboard ---
|
|
6252
6252
|
let healthData={};
|
|
6253
6253
|
let healthInterval=null;
|
|
6254
|
-
//
|
|
6255
|
-
//
|
|
6256
|
-
// for the last ~7.5 minutes (30 points × 15s refresh).
|
|
6254
|
+
// Client-side live-score trend, kept as a fallback for when the server's
|
|
6255
|
+
// anomaly-history sink isn't active. ~7.5 minutes (30 points × 15s refresh).
|
|
6257
6256
|
const SPARK_MAX = 30;
|
|
6258
6257
|
const scoreHistory = {};
|
|
6259
6258
|
function pushScore(name, score) {
|
|
@@ -6262,31 +6261,59 @@ function pushScore(name, score) {
|
|
|
6262
6261
|
arr.push(score);
|
|
6263
6262
|
if (arr.length > SPARK_MAX) arr.shift();
|
|
6264
6263
|
}
|
|
6265
|
-
|
|
6266
|
-
|
|
6264
|
+
// Server-side anomaly-score history (Q21). Populated from
|
|
6265
|
+
// /api/health/anomaly-sparklines — the last hour of omcp_anomaly_score
|
|
6266
|
+
// from the anomaly-history sink. Survives reloads; preferred over the
|
|
6267
|
+
// client-side trend when present.
|
|
6268
|
+
let anomalySpark = { enabled:false, windowMs:0, series:{} };
|
|
6269
|
+
|
|
6270
|
+
function drawSpark(values, status, domainMax, title){
|
|
6267
6271
|
const w = 100, h = 36, pad = 2;
|
|
6268
|
-
if (
|
|
6269
|
-
// Placeholder dashed midline until we have ≥2 samples.
|
|
6272
|
+
if (!values || values.length < 2) {
|
|
6270
6273
|
return `<svg class="hc-spark ${status}" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" aria-hidden="true">`
|
|
6271
6274
|
+ `<line class="spark-empty" x1="0" y1="${h/2}" x2="${w}" y2="${h/2}"/></svg>`;
|
|
6272
6275
|
}
|
|
6273
|
-
const min = 0, max =
|
|
6274
|
-
const step = (w - pad*2) / (
|
|
6275
|
-
const y = v => h - pad - ((v - min) / (max - min)) * (h - pad*2);
|
|
6276
|
-
const coords =
|
|
6276
|
+
const min = 0, max = domainMax;
|
|
6277
|
+
const step = (w - pad*2) / (values.length - 1);
|
|
6278
|
+
const y = v => h - pad - ((Math.max(min, Math.min(max, v)) - min) / (max - min || 1)) * (h - pad*2);
|
|
6279
|
+
const coords = values.map((v, i) => `${pad + i*step},${y(v)}`);
|
|
6277
6280
|
const line = coords.join(' ');
|
|
6278
|
-
const area = `M${coords[0]} L${line.split(' ').join(' L')} L${pad + (
|
|
6281
|
+
const area = `M${coords[0]} L${line.split(' ').join(' L')} L${pad + (values.length-1)*step},${h-pad} L${pad},${h-pad} Z`;
|
|
6279
6282
|
const last = coords[coords.length - 1].split(',');
|
|
6280
6283
|
return `<svg class="hc-spark ${status}" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" aria-hidden="true">`
|
|
6284
|
+
+ (title?`<title>${esc(title)}</title>`:'')
|
|
6281
6285
|
+ `<path class="spark-fill" d="${area}"/>`
|
|
6282
6286
|
+ `<polyline class="spark-line" points="${line}"/>`
|
|
6283
6287
|
+ `<circle class="spark-dot" cx="${last[0]}" cy="${last[1]}" r="1.8"/>`
|
|
6284
6288
|
+ `</svg>`;
|
|
6285
6289
|
}
|
|
6286
6290
|
|
|
6291
|
+
function sparkSvg(name, status) {
|
|
6292
|
+
// Prefer the server anomaly-score series (real omcp_anomaly_score, last
|
|
6293
|
+
// hour, survives reload). Anomaly scores are 0..1; widen the domain if a
|
|
6294
|
+
// score ever exceeds 1 so spikes aren't clipped.
|
|
6295
|
+
const series = (anomalySpark.series && anomalySpark.series[name]) || [];
|
|
6296
|
+
if (series.length >= 2) {
|
|
6297
|
+
const scores = series.map(p => p.score);
|
|
6298
|
+
const domainMax = Math.max(1, ...scores);
|
|
6299
|
+
const mins = anomalySpark.windowMs ? Math.round(anomalySpark.windowMs/60000) : 60;
|
|
6300
|
+
return drawSpark(scores, status, domainMax, `anomaly score · ${series.length} pts · last ${mins}m`);
|
|
6301
|
+
}
|
|
6302
|
+
// Fallback: client-side live health-score trend (0..100).
|
|
6303
|
+
return drawSpark(scoreHistory[name] || [], status, 100, 'live score trend');
|
|
6304
|
+
}
|
|
6305
|
+
|
|
6287
6306
|
async function loadHealthData() {
|
|
6288
6307
|
try {
|
|
6289
|
-
|
|
6308
|
+
// Fetch health + the server-side anomaly-score sparkline data in
|
|
6309
|
+
// parallel. The sparkline endpoint is best-effort: if it fails we
|
|
6310
|
+
// simply fall back to the client-side live-score trend.
|
|
6311
|
+
const [h, spark] = await Promise.all([
|
|
6312
|
+
fetch('/api/health').then(r=>r.json()),
|
|
6313
|
+
fetch('/api/health/anomaly-sparklines').then(r=>r.ok?r.json():null).catch(()=>null),
|
|
6314
|
+
]);
|
|
6315
|
+
healthData=h;
|
|
6316
|
+
if (spark && spark.series) anomalySpark = spark;
|
|
6290
6317
|
renderHealthCards();
|
|
6291
6318
|
} catch(e){ document.getElementById('health-cards').innerHTML='<div class="empty">Failed to load health data.</div>'; }
|
|
6292
6319
|
if(!healthInterval) healthInterval=setInterval(()=>{ if(document.getElementById('page-health').classList.contains('active')) loadHealthData(); },15000);
|
package/package.json
CHANGED