@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.
Files changed (43) hide show
  1. package/dist/analysis/history.d.ts +36 -2
  2. package/dist/analysis/history.js +60 -2
  3. package/dist/analysis/history.test.js +46 -0
  4. package/dist/auth/csrf.d.ts +6 -0
  5. package/dist/auth/csrf.js +4 -0
  6. package/dist/auth/csrf.test.js +22 -0
  7. package/dist/auth/lockout.d.ts +72 -0
  8. package/dist/auth/lockout.js +134 -0
  9. package/dist/auth/lockout.test.d.ts +1 -0
  10. package/dist/auth/lockout.test.js +133 -0
  11. package/dist/auth/middleware.d.ts +5 -0
  12. package/dist/auth/middleware.js +6 -1
  13. package/dist/auth/middleware.test.js +31 -0
  14. package/dist/auth/password-policy.d.ts +52 -0
  15. package/dist/auth/password-policy.js +125 -0
  16. package/dist/auth/password-policy.test.d.ts +1 -0
  17. package/dist/auth/password-policy.test.js +111 -0
  18. package/dist/auth/revocation.d.ts +93 -0
  19. package/dist/auth/revocation.js +193 -0
  20. package/dist/auth/revocation.test.d.ts +1 -0
  21. package/dist/auth/revocation.test.js +136 -0
  22. package/dist/auth/session.d.ts +7 -0
  23. package/dist/auth/session.js +6 -0
  24. package/dist/auth/session.test.js +21 -0
  25. package/dist/connectors/interface.d.ts +5 -1
  26. package/dist/connectors/loki.d.ts +45 -1
  27. package/dist/connectors/loki.js +141 -8
  28. package/dist/connectors/loki.test.js +171 -1
  29. package/dist/index.js +217 -3
  30. package/dist/openapi.js +39 -0
  31. package/dist/openapi.test.js +1 -0
  32. package/dist/security/csp.d.ts +64 -0
  33. package/dist/security/csp.js +135 -0
  34. package/dist/security/csp.test.d.ts +1 -0
  35. package/dist/security/csp.test.js +97 -0
  36. package/dist/tools/query-logs.d.ts +40 -0
  37. package/dist/tools/query-logs.js +69 -3
  38. package/dist/tools/validation.d.ts +13 -0
  39. package/dist/tools/validation.js +74 -0
  40. package/dist/tools/validation.test.js +54 -1
  41. package/dist/types.d.ts +48 -0
  42. package/dist/ui/index.html +42 -15
  43. 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";
@@ -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. Supports filtering by log level and search query.",
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";
@@ -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;
@@ -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
- // Score history per service, kept client-side. The server doesn't yet
6255
- // expose a per-service score timeseries this gives an at-a-glance trend
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
- function sparkSvg(name, status) {
6266
- const pts = scoreHistory[name] || [];
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 (pts.length < 2) {
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 = 100;
6274
- const step = (w - pad*2) / (pts.length - 1);
6275
- const y = v => h - pad - ((v - min) / (max - min)) * (h - pad*2);
6276
- const coords = pts.map((v, i) => `${pad + i*step},${y(v)}`);
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 + (pts.length-1)*step},${h-pad} L${pad},${h-pad} Z`;
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
- healthData=await(await fetch('/api/health')).json();
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thotischner/observability-mcp",
3
- "version": "3.0.1",
3
+ "version": "3.1.0",
4
4
  "description": "Unified observability gateway for AI agents — one MCP server for Prometheus, Loki, and any backend",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",