Patterns
Hot Keys
@@ -556,7 +600,14 @@ export function KeyAnalytics() {
}`}
onClick={() => setSelectedPattern(pattern.pattern)}
>
- | {pattern.pattern} |
+
+ {pattern.pattern}
+ {showDbBadges && (
+
+ db{pattern.db ?? 0}
+
+ )}
+ |
{formatNumber(pattern.keyCount)} |
{formatNumber(pattern.sampledKeyCount)}
@@ -812,6 +863,7 @@ export function KeyAnalytics() {
entry.rank,
entry.keyName,
baselineHotKeys ?? null,
+ entry.db,
);
const isTop10 = entry.rank <= 10;
return (
@@ -825,7 +877,14 @@ export function KeyAnalytics() {
{delta.label}
- {entry.keyName}
+
+ {entry.keyName}
+ {showDbBadges && (
+
+ db{entry.db ?? 0}
+
+ )}
+
{entry.signalType === 'lfu' ? (
@@ -986,7 +1045,14 @@ export function KeyAnalytics() {
{entry.rank}
- {entry.keyName}
+
+ {entry.keyName}
+ {showDbBadges && (
+
+ db{entry.db ?? 0}
+
+ )}
+
{entry.keyType ? (
{entry.keyType}
diff --git a/packages/shared/src/types/key-analytics.ts b/packages/shared/src/types/key-analytics.ts
index 77b998d6d..ee9d83caf 100644
--- a/packages/shared/src/types/key-analytics.ts
+++ b/packages/shared/src/types/key-analytics.ts
@@ -18,6 +18,8 @@ export interface KeyPatternSnapshot {
minTtlSeconds?: number;
maxTtlSeconds?: number;
connectionId?: string;
+ /** Logical database index the snapshot was collected from (defaults to 0). */
+ db?: number;
}
export interface KeyPatternQueryOptions {
@@ -27,6 +29,8 @@ export interface KeyPatternQueryOptions {
limit?: number;
offset?: number;
connectionId?: string;
+ /** Restrict to one logical database index. */
+ db?: number;
}
export interface KeyAnalyticsSummary {
@@ -106,6 +110,8 @@ export interface KeyPatternData {
export interface KeyDetail {
keyName: string;
keyType: string | null;
+ /** Logical database index the key lives in (defaults to 0). */
+ db?: number;
/** #elements for collections, byte length for strings (valkey #1827 "bigkey" metric) */
cardinality: number | null;
freqScore: number | null;
@@ -119,6 +125,8 @@ export interface KeyAnalyticsResult {
scanned: number;
patterns: KeyPatternData[];
keyDetails?: KeyDetail[];
+ /** Logical database index the result was collected from (defaults to 0). */
+ db?: number;
}
/**
@@ -145,6 +153,8 @@ export interface HotKeyEntry {
keyType?: string;
ttl?: number;
rank: number;
+ /** Logical database index the key lives in (defaults to 0). */
+ db?: number;
}
export interface HotKeyQueryOptions {
@@ -157,4 +167,6 @@ export interface HotKeyQueryOptions {
oldest?: boolean;
/** Restrict to these signal types. Defaults to access signals (`lfu`, `idletime`). */
signalTypes?: Array;
+ /** Restrict to one logical database index. */
+ db?: number;
}
diff --git a/proprietary/agent/agent-database-adapter.ts b/proprietary/agent/agent-database-adapter.ts
index c62479c33..7f37235c5 100644
--- a/proprietary/agent/agent-database-adapter.ts
+++ b/proprietary/agent/agent-database-adapter.ts
@@ -477,9 +477,11 @@ export class AgentDatabaseAdapter implements DatabasePort {
return (await this.sendCommand('LASTSAVE')) as number;
}
- async collectKeyAnalytics(options: KeyAnalyticsOptions): Promise {
+ async collectKeyAnalytics(options: KeyAnalyticsOptions): Promise {
const response = await this.sendCommand('COLLECT_KEY_ANALYTICS', [JSON.stringify(options)]);
- return JSON.parse(response as string);
+ // The agent scans its connected database only; normalize to the
+ // one-result-per-database shape of the port.
+ return [JSON.parse(response as string)];
}
async getHashFieldBuffer(key: string, field: string): Promise {
diff --git a/proprietary/key-analytics/__tests__/composite-key-ranker.spec.ts b/proprietary/key-analytics/__tests__/composite-key-ranker.spec.ts
index a401eb836..9a590c0fd 100644
--- a/proprietary/key-analytics/__tests__/composite-key-ranker.spec.ts
+++ b/proprietary/key-analytics/__tests__/composite-key-ranker.spec.ts
@@ -144,3 +144,29 @@ describe('rankCompositeKeys', () => {
).toEqual([]);
});
});
+
+describe('rankCompositeKeys across logical databases', () => {
+ it('treats the same key name in two databases as two distinct keys', () => {
+ const candidates: CompositeCandidate[] = [
+ candidate({ keyName: 'shared', db: 0, freqScore: 200, cardinality: 5000 }),
+ candidate({ keyName: 'shared', db: 1, freqScore: 100, cardinality: 2500 }),
+ ];
+
+ const ranked = rankCompositeKeys(candidates, 5);
+
+ // Both place on both dimensions independently — no cross-db merge.
+ expect(ranked).toHaveLength(2);
+ expect(ranked.map((r) => [r.keyName, r.db])).toEqual([
+ ['shared', 0],
+ ['shared', 1],
+ ]);
+ });
+
+ it('passes the db through on the ranked entry', () => {
+ const ranked = rankCompositeKeys(
+ [candidate({ keyName: 'hotbig', db: 3, freqScore: 10, cardinality: 10 })],
+ 5,
+ );
+ expect(ranked[0].db).toBe(3);
+ });
+});
diff --git a/proprietary/key-analytics/__tests__/key-analytics-topology.spec.ts b/proprietary/key-analytics/__tests__/key-analytics-topology.spec.ts
index de68890c1..75e6a2839 100644
--- a/proprietary/key-analytics/__tests__/key-analytics-topology.spec.ts
+++ b/proprietary/key-analytics/__tests__/key-analytics-topology.spec.ts
@@ -191,7 +191,8 @@ interface FakeClient {
function fakeClient(role: 'master' | 'slave' | 'sentinel', result: KeyAnalyticsResult | Error, keySizes = ''): FakeClient {
return {
getRole: jest.fn().mockResolvedValue({ role }),
- collectKeyAnalytics: result instanceof Error ? jest.fn().mockRejectedValue(result) : jest.fn().mockResolvedValue(result),
+ // The port returns one result per scanned logical database.
+ collectKeyAnalytics: result instanceof Error ? jest.fn().mockRejectedValue(result) : jest.fn().mockResolvedValue([result]),
call: jest.fn().mockResolvedValue(keySizes),
getCapabilities: () => ({ isSentinel: role === 'sentinel' }),
};
@@ -346,6 +347,46 @@ describe('KeyAnalyticsService with topology members', () => {
expect(snapshots[0]).toMatchObject({ pattern: 'user:*', keyCount: 50 });
});
+ it('keeps the per-database results of one instance separate and db-tags the hot keys', async () => {
+ const { poll, clients, storage } = cluster();
+ // The standalone instance holds keys in db0 and db1 — one result each.
+ clients.standalone.collectKeyAnalytics.mockResolvedValue([
+ { ...scanOf(50), db: 0 },
+ { ...scanOf(70, 'job:*'), db: 1 },
+ ]);
+
+ await poll('standalone');
+
+ const [snapshots] = storage.saveKeyPatternSnapshots.mock.calls[0] as [KeyPatternSnapshot[]];
+ expect(snapshots.map((s) => [s.pattern, s.db, s.keyCount]).sort()).toEqual([
+ ['job:*', 1, 70],
+ ['user:*', 0, 50],
+ ]);
+
+ const [hotKeys] = storage.saveHotKeys.mock.calls[0] as [Array<{ keyName: string; signalType: string; db?: number }>];
+ const cardinality = hotKeys.filter((row) => row.signalType === 'cardinality');
+ expect(cardinality.map((row) => [row.keyName, row.db])).toEqual([
+ ['job:*:70', 1],
+ ['user:*:50', 0],
+ ]);
+ });
+
+ it('keeps the same pattern apart across logical databases instead of merging it', async () => {
+ const { poll, clients, storage } = cluster();
+ clients.standalone.collectKeyAnalytics.mockResolvedValue([
+ { ...scanOf(50), db: 0 },
+ { ...scanOf(70), db: 1 },
+ ]);
+
+ await poll('standalone');
+
+ const [snapshots] = storage.saveKeyPatternSnapshots.mock.calls[0] as [KeyPatternSnapshot[]];
+ expect(snapshots.map((s) => [s.pattern, s.db, s.keyCount]).sort()).toEqual([
+ ['user:*', 0, 50],
+ ['user:*', 1, 70],
+ ]);
+ });
+
it('scans a sentinel primary but not its replicas or a retired node', async () => {
const connections = [
connection('sentinel'),
diff --git a/proprietary/key-analytics/__tests__/key-analytics.service.spec.ts b/proprietary/key-analytics/__tests__/key-analytics.service.spec.ts
index 3cbbd61a9..2fdfae60b 100644
--- a/proprietary/key-analytics/__tests__/key-analytics.service.spec.ts
+++ b/proprietary/key-analytics/__tests__/key-analytics.service.spec.ts
@@ -94,7 +94,8 @@ describe('KeyAnalyticsService.collect composite persistence', () => {
client: { collectKeyAnalytics: jest.Mock };
} {
const client = {
- collectKeyAnalytics: jest.fn().mockResolvedValue({
+ // One result per scanned logical database.
+ collectKeyAnalytics: jest.fn().mockResolvedValue([{
dbSize: 3,
scanned: 3,
patterns: [],
@@ -106,7 +107,7 @@ describe('KeyAnalyticsService.collect composite persistence', () => {
// Big but LFU-cold (freq 0 is dropped) -> cardinality dimension only.
{ keyName: 'onlybig', keyType: 'hash', freqScore: 0, idleSeconds: null, memoryBytes: 900, cardinality: 4000, ttl: null },
],
- }),
+ }]),
};
const storage = {
saveHotKeys,
diff --git a/proprietary/key-analytics/__tests__/key-pattern-snapshots.spec.ts b/proprietary/key-analytics/__tests__/key-pattern-snapshots.spec.ts
index cb1f83e84..aa54a5c02 100644
--- a/proprietary/key-analytics/__tests__/key-pattern-snapshots.spec.ts
+++ b/proprietary/key-analytics/__tests__/key-pattern-snapshots.spec.ts
@@ -1,5 +1,5 @@
import type { KeyPatternData } from '@betterdb/shared';
-import { buildPatternSnapshots } from '../key-pattern-snapshots';
+import { buildPatternSnapshots, mergePatternSnapshots } from '../key-pattern-snapshots';
const DAY = 86400;
@@ -76,3 +76,33 @@ describe('buildPatternSnapshots stale key count', () => {
expect(snapshot.coldKeyCount).toBe(2);
});
});
+
+describe('pattern snapshots across logical databases', () => {
+ it('tags each snapshot with the logical database of its scan result', () => {
+ const [snapshot] = buildPatternSnapshots(
+ { dbSize: 4, scanned: 4, patterns: [pattern()], db: 2 },
+ 1,
+ );
+ expect(snapshot.db).toBe(2);
+ });
+
+ it('defaults the snapshot db to 0 for results without one (older agents)', () => {
+ const [snapshot] = buildPatternSnapshots({ dbSize: 4, scanned: 4, patterns: [pattern()] }, 1);
+ expect(snapshot.db).toBe(0);
+ });
+
+ it('merges a pattern across cluster nodes but never across logical databases', () => {
+ const nodeA = buildPatternSnapshots({ dbSize: 4, scanned: 4, patterns: [pattern()], db: 0 }, 1);
+ const nodeB = buildPatternSnapshots({ dbSize: 8, scanned: 8, patterns: [pattern({ count: 8, totalMemory: 800 })], db: 0 }, 1);
+ const otherDb = buildPatternSnapshots({ dbSize: 2, scanned: 2, patterns: [pattern({ count: 2, totalMemory: 200 })], db: 1 }, 1);
+
+ const merged = mergePatternSnapshots([nodeA, nodeB, otherDb]);
+
+ // session:* in db0 (merged across the two nodes) and session:* in db1.
+ expect(merged).toHaveLength(2);
+ const db0 = merged.find((s) => s.db === 0);
+ const db1 = merged.find((s) => s.db === 1);
+ expect(db0?.keyCount).toBe(12);
+ expect(db1?.keyCount).toBe(2);
+ });
+});
diff --git a/proprietary/key-analytics/composite-key-ranker.ts b/proprietary/key-analytics/composite-key-ranker.ts
index eef73dc48..544322790 100644
--- a/proprietary/key-analytics/composite-key-ranker.ts
+++ b/proprietary/key-analytics/composite-key-ranker.ts
@@ -39,6 +39,8 @@ export type CompositeDimension = 'hotness' | 'cardinality';
export interface CompositeCandidate {
keyName: string;
keyType: string | null;
+ /** Logical database index the key lives in; part of the key's identity. */
+ db?: number;
/** LFU access frequency (OBJECT FREQ). Null unless an LFU maxmemory-policy is set. */
freqScore: number | null;
/** Idle seconds (OBJECT IDLETIME). The recency fallback when freqScore is absent. */
@@ -52,6 +54,8 @@ export interface CompositeCandidate {
export interface CompositeKeyRank {
keyName: string;
keyType: string | null;
+ /** Logical database index the key lives in, passed through from the candidate. */
+ db?: number;
ttl: number | null;
/** Dimensions on which the key placed in that dimension's top-N (length >= 2). */
dimensions: CompositeDimension[];
@@ -131,7 +135,12 @@ export function rankCompositeKeys(
return [];
}
- // keyName -> (dimension -> ranking value) for the dimensions where the key made top-N.
+ // The same key name in two logical databases is two distinct keys, so the
+ // identity used for membership / dedupe includes the database index.
+ const identityOf = (candidate: CompositeCandidate): string =>
+ `${candidate.db ?? 0} ${candidate.keyName}`;
+
+ // key identity -> (dimension -> ranking value) for the dimensions where the key made top-N.
const membership = new Map>();
const dimensionMax = new Map();
@@ -147,10 +156,10 @@ export function rankCompositeKeys(
dimensionMax.set(dimension, withValue[0].value);
for (const { candidate, value } of withValue.slice(0, perDimensionTopN)) {
- let dims = membership.get(candidate.keyName);
+ let dims = membership.get(identityOf(candidate));
if (!dims) {
dims = new Map();
- membership.set(candidate.keyName, dims);
+ membership.set(identityOf(candidate), dims);
}
// Keep the largest value if a key somehow appears twice for a dimension.
dims.set(dimension, Math.max(value, dims.get(dimension) ?? value));
@@ -161,14 +170,14 @@ export function rankCompositeKeys(
const seen = new Set();
for (const candidate of candidates) {
- if (seen.has(candidate.keyName)) {
+ if (seen.has(identityOf(candidate))) {
continue;
}
- const dims = membership.get(candidate.keyName);
+ const dims = membership.get(identityOf(candidate));
if (!dims || dims.size < COMPOSITE_MIN_DIMENSIONS) {
continue;
}
- seen.add(candidate.keyName);
+ seen.add(identityOf(candidate));
let score = 0;
for (const [dimension, value] of dims) {
@@ -182,6 +191,7 @@ export function rankCompositeKeys(
ranked.push({
keyName: candidate.keyName,
keyType: candidate.keyType,
+ db: candidate.db,
ttl: candidate.ttl,
dimensions: [...dims.keys()],
// Report the raw signal the hotness placement came from, not the derived magnitude.
diff --git a/proprietary/key-analytics/key-analytics.controller.ts b/proprietary/key-analytics/key-analytics.controller.ts
index f2a65216d..63660cdef 100644
--- a/proprietary/key-analytics/key-analytics.controller.ts
+++ b/proprietary/key-analytics/key-analytics.controller.ts
@@ -26,10 +26,11 @@ export class KeyAnalyticsController {
@ConnectionId() connectionId?: string,
@Query('startTime') startTime?: string,
@Query('endTime') endTime?: string,
+ @Query('db') db?: string,
) {
const start = startTime ? parseInt(startTime, 10) : undefined;
const end = endTime ? parseInt(endTime, 10) : undefined;
- return this.keyAnalytics.getSummary(start, end, connectionId);
+ return this.keyAnalytics.getSummary(start, end, connectionId, safeInt(db));
}
@Get('patterns')
@@ -42,6 +43,7 @@ export class KeyAnalyticsController {
@Query('startTime') startTime?: string,
@Query('endTime') endTime?: string,
@Query('limit') limit?: string,
+ @Query('db') db?: string,
) {
return this.keyAnalytics.getPatternSnapshots({
pattern,
@@ -49,6 +51,7 @@ export class KeyAnalyticsController {
endTime: endTime ? parseInt(endTime, 10) : undefined,
limit: limit ? parseInt(limit, 10) : undefined,
connectionId,
+ db: safeInt(db),
});
}
@@ -63,6 +66,7 @@ export class KeyAnalyticsController {
@Query('endTime') endTime?: string,
@Query('latest') latest?: string,
@Query('oldest') oldest?: string,
+ @Query('db') db?: string,
) {
return this.keyAnalytics.getHotKeys({
connectionId,
@@ -71,6 +75,7 @@ export class KeyAnalyticsController {
endTime: safeInt(endTime),
latest: latest === 'true',
oldest: oldest === 'true',
+ db: safeInt(db),
});
}
@@ -85,6 +90,7 @@ export class KeyAnalyticsController {
@Query('endTime') endTime?: string,
@Query('latest') latest?: string,
@Query('oldest') oldest?: string,
+ @Query('db') db?: string,
) {
const wantOldest = oldest === 'true';
return this.keyAnalytics.getLargestKeys({
@@ -95,6 +101,7 @@ export class KeyAnalyticsController {
// Default to the latest snapshot unless an explicit time range or oldest is requested.
latest: wantOldest ? false : latest !== 'false',
oldest: wantOldest,
+ db: safeInt(db),
});
}
@@ -109,6 +116,7 @@ export class KeyAnalyticsController {
@Query('endTime') endTime?: string,
@Query('latest') latest?: string,
@Query('oldest') oldest?: string,
+ @Query('db') db?: string,
) {
const wantOldest = oldest === 'true';
return this.keyAnalytics.getCompositeKeys({
@@ -119,6 +127,7 @@ export class KeyAnalyticsController {
// Default to the latest snapshot unless an explicit time range or oldest is requested.
latest: wantOldest ? false : latest !== 'false',
oldest: wantOldest,
+ db: safeInt(db),
});
}
@@ -131,11 +140,12 @@ export class KeyAnalyticsController {
@Query('pattern') pattern?: string,
@Query('startTime') startTime?: string,
@Query('endTime') endTime?: string,
+ @Query('db') db?: string,
) {
if (!pattern || !startTime || !endTime) {
throw new Error('pattern, startTime, and endTime are required');
}
- return this.keyAnalytics.getPatternTrends(pattern, parseInt(startTime, 10), parseInt(endTime, 10), connectionId);
+ return this.keyAnalytics.getPatternTrends(pattern, parseInt(startTime, 10), parseInt(endTime, 10), connectionId, safeInt(db));
}
@Post('collect')
@@ -151,6 +161,14 @@ export class KeyAnalyticsController {
};
}
+ @Get('databases')
+ @UseGuards(LicenseGuard)
+ @RequiresFeature('keyAnalytics')
+ @ApiHeader({ name: CONNECTION_ID_HEADER, required: false, description: 'Connection ID to filter by' })
+ async getDatabases(@ConnectionId() connectionId?: string) {
+ return this.keyAnalytics.getDatabases(connectionId);
+ }
+
@Get('key-sizes')
@UseGuards(LicenseGuard)
@RequiresFeature('keyAnalytics')
diff --git a/proprietary/key-analytics/key-analytics.service.ts b/proprietary/key-analytics/key-analytics.service.ts
index 1dd625caec41297291659bf61b92175f92afc91b..6c4a9e634fce5ef43d4cc2496b484e1bf7b779bd 100644
GIT binary patch
delta 1190
zcmZXUyKfUg5XLcih+LEuP=G;!35xJJa?U^qiDD%dC?XQChICet*S8yc?eng6yCE^b
z3O!AvrK3U!geZ9EI%uGxrK6{aXb}GbvuB?Zk!yV3{l3|m-^|{tx2dn6QXg+RyZJn<
z)fgz!WYA!35cb6<0@yZJ8n^((9&yReje7d!ZhK5vJ^|}S+7Z2uG^qlSXu^m2M|qz=73ffdnF~np_9$#dhxOa
z$1&pSM5HG7hejb*Hz+Ejdzs;Z!waUKpNQ!6-_(eW7;DUpOX=ug?o6Wd8`fCQ?+u*o
zc3t7*2}V~c3YI{yEqK7R`3C7by&8HGki{(QxB>KRt&kB4I0L<>D_35?L={`N3|IPF
zV1CY?-<{eyILcG}F|!&dLitFCtxkc9Cr|B^5Yd5e-BU8uu1j)Ii5U`@!8flixo|TU
zBxJIkY#A0I(+YW$*luS!WlUu(Nffs-1@mY8v~gy?46C?~Xz&_Mjah$j5yWP*!b0d6NKRpdA$xS^{
zb9@_dGUK^dT84}Q5hQ49?km`kVSE{oy();
for (const snapshot of perNode.flat()) {
- byPattern.set(snapshot.pattern, [...(byPattern.get(snapshot.pattern) ?? []), snapshot]);
+ const identity = `${snapshot.db ?? 0} ${snapshot.pattern}`;
+ byPattern.set(identity, [...(byPattern.get(identity) ?? []), snapshot]);
}
return Array.from(byPattern.values()).map((group) => (group.length === 1 ? group[0] : mergeGroup(group)));
}
@@ -103,6 +108,7 @@ function mergeGroup(group: KeyPatternSnapshot[]): KeyPatternSnapshot {
id: randomUUID(),
timestamp: group[0].timestamp,
pattern: group[0].pattern,
+ db: group[0].db,
keyCount: sum(keyCount),
sampledKeyCount: sum((snapshot) => snapshot.sampledKeyCount),
keysWithTtl: sum((snapshot) => snapshot.keysWithTtl),
From 9ea709b1627623d3294b1b095d38dd832a79702f Mon Sep 17 00:00:00 2001
From: Kristiyan Ivanov
Date: Thu, 15 Oct 2026 20:27:48 +0300
Subject: [PATCH 02/17] fix(key-analytics): use a plain space in the dedupe
identity, not a raw NUL byte
The embedded \x00 made git treat the file as binary.
---
.../key-analytics/key-analytics.service.ts | Bin 22720 -> 22720 bytes
1 file changed, 0 insertions(+), 0 deletions(-)
diff --git a/proprietary/key-analytics/key-analytics.service.ts b/proprietary/key-analytics/key-analytics.service.ts
index 6c4a9e634fce5ef43d4cc2496b484e1bf7b779bd..aaec04093293650eb9938df3430569b432d9c1b5 100644
GIT binary patch
delta 16
YcmX@Gk@3Jr#tmP&7!@{u<2vOF078BTe*gdg
delta 16
YcmX@Gk@3Jr#tmP&7#TKy<2vOF071S7UjP6A
From 51bd46d4c1731ac72c054e8bd9af866546ec71d7 Mon Sep 17 00:00:00 2001
From: Kristiyan Ivanov
Date: Fri, 16 Oct 2026 07:27:07 +0300
Subject: [PATCH 03/17] fix(key-analytics): apply code-review fixes for
multi-db support (#542)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(api): avoid int4 overflow in key analytics weighted avg memory query
SUM(avg_memory_bytes * sampled_key_count) multiplies two int4 columns,
which Postgres evaluates as int4 and can overflow to "integer out of
range" on large patterns. Cast avg_memory_bytes to bigint first.
* fix(api): harden multi-db key analytics scan and connection dbIndex handling
- collectKeyAnalytics: wrap the own-db scan in the same try/catch as the
scratch-client branch so a failure on the connection's own database no
longer aborts the loop and discards sibling results.
- collectKeyAnalytics: attach an error listener to each scratch client
(iovalkey otherwise logs raw errors via console.error), explicitly
SELECT after connecting so a SELECT iovalkey would silently swallow
(e.g. restricted ACL) surfaces as a caught per-db failure instead of
silently scanning db0, and skip a database when the SSH tunnel is down
instead of dialing the raw host.
- doConnect: explicitly SELECT when dbIndex > 0 so an invalid dbIndex
(out of range, or illegal in cluster mode) surfaces as a connection
error instead of silently running on db0.
- testConnection: thread dbIndex into the adapter it builds, so Test
Connection validates the same logical database the saved connection
will use.
- adoptChild: force dbIndex to 0 when adopting a connection as a cluster
member, matching addManagedChild, since SELECT > 0 is illegal in
cluster mode.
* fix(api): stable ordering and per-connection grouping for key pattern queries
- getKeyPatternSnapshots (sqlite + postgres): ORDER BY timestamp DESC had
no tiebreaker, so when one collection writes patterns x dbs rows at a
single timestamp, LIMIT truncated an arbitrary subset of them. Add
db ASC, pattern ASC as tiebreakers.
- getKeyPatternTrends (sqlite + postgres): GROUP BY timestamp merged rows
from different connections together when connectionId is not provided.
Group by (timestamp, connection_id) in that case so each connection
keeps its own series, same as before the multi-db change; keep plain
GROUP BY timestamp when a connectionId scopes the query.
* perf(api): index key_pattern_snapshots(connection_id, db)
The database selector polls SELECT DISTINCT db every 60s; back it with
a composite index on (connection_id, db) in both adapters. Added after
the db column's own migration/ALTER so it only runs once the column
exists.
* fix(web): scope hot-key baseline and pattern selection to the active db
- useBaselineHotKeys: accept a db option, pass it to getHotKeys and
include it in the query key; KeyAnalytics now passes the active
dbFilter so rank deltas compare the same logical database instead of
mixing dbs into one baseline.
- KeyAnalytics: the patterns table renders one row per (db, pattern) but
selection tracked only the pattern name, so selecting a pattern that
exists in more than one db highlighted/opened the wrong row. Selection
state now carries { db, pattern }, compared on both fields for the row
highlight and the detail lookup; the detail card title shows the db
when the connection has more than one.
- KeyAnalytics: reset the db filter to 'all' whenever the active
connection changes, so switching connections doesn't keep querying
with a stale numeric db filter while its database list reloads, or
silently snap back from 'all' if the remembered db happens to also
exist on the new connection.
* test(api): cover own-db failure isolation, scratch SELECT hardening, and stable snapshot ordering
- unified.adapter.spec.ts: a failing own-db scan no longer discards
sibling results; a scratch client whose SELECT is rejected (e.g.
restricted ACL) is treated as a failed scan instead of silently
tagging its keys with the wrong db; a sibling db is skipped rather
than dialing the raw host when the SSH tunnel is down.
- key-analytics-multi-db.spec.ts: snapshots tied on timestamp are
returned in a deterministic (db, pattern) order instead of an
arbitrary LIMIT-truncated subset; trends from different connections
stay separate series when no connectionId scopes the query.
* fix(api): disconnect the client when a post-connect step fails
doConnect's catch block only tore down the SSH tunnel, not the client
itself. The new dbIndex SELECT (and the pre-existing detectCapabilities
call) run after the socket has already connected and authenticated, so
a failure there left a live, authenticated session open on a direct
connection — e.g. every failed Test Connection attempt with an invalid
or cluster-illegal dbIndex built and abandoned its own adapter. Disconnect
and discard the client on any connect failure, not just a tunneled one.
Found by Cursor Bugbot on PR #542.
---------
---
.../connection-registry.service.ts | 12 +++
.../database/adapters/unified.adapter.spec.ts | 89 +++++++++++++++++++
.../src/database/adapters/unified.adapter.ts | 44 ++++++++-
.../__tests__/key-analytics-multi-db.spec.ts | 32 +++++++
.../src/storage/adapters/postgres.adapter.ts | 12 ++-
.../src/storage/adapters/sqlite.adapter.ts | 14 ++-
apps/web/src/hooks/useBaselineHotKeys.ts | 5 +-
apps/web/src/pages/KeyAnalytics.tsx | 30 +++++--
8 files changed, 224 insertions(+), 14 deletions(-)
diff --git a/apps/api/src/connections/connection-registry.service.ts b/apps/api/src/connections/connection-registry.service.ts
index 609022a2c..ed26445b2 100644
--- a/apps/api/src/connections/connection-registry.service.ts
+++ b/apps/api/src/connections/connection-registry.service.ts
@@ -803,6 +803,7 @@ export class ConnectionRegistry implements OnModuleInit, OnModuleDestroy {
username: request.username || 'default',
password: request.password || '',
tls: request.tls,
+ dbIndex: request.dbIndex,
// Unique id so a test tunnel never collides with a live connection's tunnel.
connectionId: `test:${randomUUID()}`,
sshTunnel: this.sanitizeSshTunnelInput(request.sshTunnel),
@@ -1153,6 +1154,17 @@ export class ConnectionRegistry implements OnModuleInit, OnModuleDestroy {
async adoptChild(id: string, seedId: string, node: DesiredNode): Promise {
await this.setMembership(id, { seedId, origin: 'adopted', ...membershipFields(node) });
+ if (node.source === 'cluster') {
+ // SELECT on a non-zero db is illegal in cluster mode; force db0 like
+ // addManagedChild does for auto-registered cluster members, since an
+ // adopted connection may have been configured with a non-zero dbIndex
+ // before joining the cluster.
+ const config = this.configs.get(id);
+ if (config && (config.dbIndex ?? 0) !== 0) {
+ await this.storage.updateConnection(id, { dbIndex: 0 });
+ this.configs.set(id, { ...config, dbIndex: 0 });
+ }
+ }
}
async refreshChild(id: string, node: DesiredNode): Promise {
diff --git a/apps/api/src/database/adapters/unified.adapter.spec.ts b/apps/api/src/database/adapters/unified.adapter.spec.ts
index fa36d2944..f27daee9b 100644
--- a/apps/api/src/database/adapters/unified.adapter.spec.ts
+++ b/apps/api/src/database/adapters/unified.adapter.spec.ts
@@ -234,6 +234,8 @@ describe('UnifiedDatabaseAdapter.collectKeyAnalytics — multiple logical databa
scan: jest.fn().mockResolvedValue(['0', keys]),
pipeline: scanChain,
connect: jest.fn().mockResolvedValue(undefined),
+ select: jest.fn().mockResolvedValue('OK'),
+ on: jest.fn(),
quit: jest.fn().mockResolvedValue('OK'),
disconnect: jest.fn(),
};
@@ -349,6 +351,63 @@ describe('UnifiedDatabaseAdapter.collectKeyAnalytics — multiple logical databa
expect(results.map((r) => r.db)).toEqual([0, 2]);
expect(scratchClients[2].quit).toHaveBeenCalled();
});
+
+ it('keeps scanning sibling databases when the own-db scan fails', async () => {
+ const { adapter, ownClient, scratchClients } = makeMultiDbAdapter({
+ dbIndex: 0,
+ keyspaceInfo: 'db0:keys=1,expires=0,avg_ttl=0\r\ndb1:keys=1,expires=0,avg_ttl=0\r\n',
+ ownKeys: ['a:1'],
+ scratchKeysByDb: { 1: ['b:1'] },
+ });
+ ownClient.scan.mockRejectedValue(new Error('own-db scan failed'));
+
+ const results = await adapter.collectKeyAnalytics({ sampleSize: 100, scanBatchSize: 100 });
+
+ // The old implementation let an own-db failure propagate out of the loop
+ // and discard sibling results. Now db1 still comes back.
+ expect(results.map((r) => r.db)).toEqual([1]);
+ expect(scratchClients[1].quit).toHaveBeenCalled();
+ });
+
+ it('treats a scratch client that cannot SELECT the target db as a failed scan', async () => {
+ const { adapter, scratchClients } = makeMultiDbAdapter({
+ dbIndex: 0,
+ keyspaceInfo: 'db0:keys=1,expires=0,avg_ttl=0\r\ndb1:keys=1,expires=0,avg_ttl=0\r\n',
+ ownKeys: ['a:1'],
+ scratchKeysByDb: { 1: ['b:1'] },
+ });
+ // iovalkey silently swallows a failed handshake SELECT; the explicit
+ // post-connect select() is what must surface this as a failure.
+ const original = (adapter as unknown as { createValkeyClient: jest.Mock })
+ .createValkeyClient;
+ original.mockImplementationOnce((_name: string, _db?: number) => {
+ const restricted = fakeScanClient(['b:1']);
+ restricted.select.mockRejectedValue(new Error('NOPERM this user has no permissions'));
+ return restricted;
+ });
+
+ const results = await adapter.collectKeyAnalytics({ sampleSize: 100, scanBatchSize: 100 });
+
+ // db1 must not appear tagged with data actually scanned from db0.
+ expect(results.map((r) => r.db)).toEqual([0]);
+ expect(scratchClients[1]).toBeUndefined();
+ });
+
+ it('skips a sibling database instead of dialing the raw host when the SSH tunnel is down', async () => {
+ const { adapter, createValkeyClient } = makeMultiDbAdapter({
+ dbIndex: 0,
+ keyspaceInfo: 'db0:keys=1,expires=0,avg_ttl=0\r\ndb1:keys=1,expires=0,avg_ttl=0\r\n',
+ ownKeys: ['a:1'],
+ scratchKeysByDb: { 1: ['b:1'] },
+ });
+ (adapter as unknown as { usesTunnel: boolean }).usesTunnel = true;
+ (adapter as unknown as { tunnelActive: boolean }).tunnelActive = false;
+
+ const results = await adapter.collectKeyAnalytics({ sampleSize: 100, scanBatchSize: 100 });
+
+ expect(results.map((r) => r.db)).toEqual([0]);
+ expect(createValkeyClient).not.toHaveBeenCalled();
+ });
});
describe('UnifiedDatabaseAdapter — configured dbIndex', () => {
@@ -375,4 +434,34 @@ describe('UnifiedDatabaseAdapter — configured dbIndex', () => {
const client = adapter.getClient() as unknown as { options: { db?: number } };
expect(client.options.db).toBe(0);
});
+
+ it('surfaces a SELECT failure on an invalid dbIndex and disconnects the leaked client', async () => {
+ const adapter = Object.create(UnifiedDatabaseAdapter.prototype) as UnifiedDatabaseAdapter;
+ const client = {
+ options: { host: 'localhost', port: 6379 },
+ connect: jest.fn().mockResolvedValue(undefined),
+ select: jest.fn().mockRejectedValue(new Error('ERR DB index is out of range')),
+ disconnect: jest.fn(),
+ };
+ (adapter as unknown as { _client: unknown })._client = client;
+ (adapter as unknown as { config: unknown }).config = { dbIndex: 5 };
+ (adapter as unknown as { usesTunnel: boolean }).usesTunnel = false;
+ (adapter as unknown as { tunnelActive: boolean }).tunnelActive = false;
+ (adapter as unknown as { logger: unknown }).logger = {
+ debug: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ log: jest.fn(),
+ };
+
+ await expect(
+ (adapter as unknown as { doConnect(): Promise }).doConnect(),
+ ).rejects.toThrow('ERR DB index is out of range');
+
+ // The socket connected and authenticated before SELECT failed; without an
+ // explicit disconnect here it would stay open, leaking one live session
+ // per failed attempt (e.g. each failed Test Connection with a bad dbIndex).
+ expect(client.disconnect).toHaveBeenCalled();
+ expect((adapter as unknown as { _client: unknown })._client).toBeNull();
+ });
});
diff --git a/apps/api/src/database/adapters/unified.adapter.ts b/apps/api/src/database/adapters/unified.adapter.ts
index 526446b67..38958a705 100644
--- a/apps/api/src/database/adapters/unified.adapter.ts
+++ b/apps/api/src/database/adapters/unified.adapter.ts
@@ -297,6 +297,14 @@ export class UnifiedDatabaseAdapter implements DatabasePort {
}
this.logger.log(`Connecting to ${this.client.options.host}:${this.client.options.port}...`);
await this.client.connect();
+ // iovalkey silently swallows a failed handshake SELECT (silentEmit), so an
+ // invalid dbIndex (out of range, or SELECT rejected in cluster mode) would
+ // otherwise leave the client connected and quietly running on db0. Select
+ // explicitly so that failure surfaces here as a connection error.
+ const dbIndex = this.config.dbIndex ?? 0;
+ if (dbIndex > 0) {
+ await this.client.select(dbIndex);
+ }
this.connected = true;
this.logger.log('Connected successfully');
await this.detectCapabilities();
@@ -304,6 +312,16 @@ export class UnifiedDatabaseAdapter implements DatabasePort {
} catch (error) {
this.connected = false;
await this.teardownTunnel();
+ // A later setup step (the dbIndex SELECT, detectCapabilities) can fail
+ // after the socket already connected and authenticated. teardownTunnel
+ // only discards the client for a tunneled connection, so a direct
+ // connection's client would otherwise stay open — leaking a live
+ // session per failed attempt (e.g. each failed Test Connection call
+ // with a bad dbIndex builds its own throwaway adapter).
+ if (this._client) {
+ this._client.disconnect();
+ this._client = null;
+ }
this.logger.error(`Connection failed: ${error instanceof Error ? error.message : error}`);
throw error;
}
@@ -862,15 +880,39 @@ export class UnifiedDatabaseAdapter implements DatabasePort {
// than today's single-db scan.
for (const db of databases) {
if (db === ownDb) {
- results.push(await this.scanDatabase(this.client, db, options));
+ try {
+ results.push(await this.scanDatabase(this.client, db, options));
+ } catch (error) {
+ failures.push(error);
+ this.logger.warn(
+ `Key analytics scan failed on db${db}: ${error instanceof Error ? error.message : error}`,
+ );
+ }
+ continue;
+ }
+ // After a tunnel drop, connectHost/connectPort point at the real (possibly
+ // bastion-only) endpoint; a scratch client built now would dial it directly
+ // and bypass the tunnel. Treat the database as failed instead, mirroring
+ // getCliClient's guard.
+ if (this.usesTunnel && !this.tunnelActive) {
+ failures.push(new Error('SSH tunnel is not established'));
+ this.logger.warn(`Key analytics scan skipped on db${db}: SSH tunnel is not established`);
continue;
}
const scratch = this.createValkeyClient(
`${this.config.connectionName ?? 'BetterDB-Monitor'}-keyscan-db${db}`,
db,
);
+ scratch.on('error', (err) => {
+ this.logger.debug(`Scratch client error on db${db}: ${err.message}`);
+ });
try {
await scratch.connect();
+ // iovalkey silently swallows a failed handshake SELECT (silentEmit), so
+ // a client that cannot SELECT this db (e.g. a restricted ACL) would
+ // otherwise keep scanning db0 while results are tagged with `db`. Select
+ // explicitly so that failure surfaces here as a caught per-db failure.
+ await scratch.select(db);
results.push(await this.scanDatabase(scratch, db, options));
} catch (error) {
failures.push(error);
diff --git a/apps/api/src/storage/adapters/__tests__/key-analytics-multi-db.spec.ts b/apps/api/src/storage/adapters/__tests__/key-analytics-multi-db.spec.ts
index ce554f653..b1ad9e69c 100644
--- a/apps/api/src/storage/adapters/__tests__/key-analytics-multi-db.spec.ts
+++ b/apps/api/src/storage/adapters/__tests__/key-analytics-multi-db.spec.ts
@@ -112,6 +112,38 @@ describe('SqliteAdapter key analytics per logical database', () => {
expect(db1).toEqual([{ timestamp: 1_000, keyCount: 30, memoryBytes: 100, staleCount: 0 }]);
});
+ it('orders tied timestamps deterministically by db then pattern instead of truncating an arbitrary subset', async () => {
+ // One collection across 2 dbs x 2 patterns writes 4 rows at the same
+ // timestamp. A LIMIT smaller than that must keep a stable, deterministic
+ // prefix (db ASC, pattern ASC) rather than whatever order SQLite happens
+ // to return ties in.
+ await storage.saveKeyPatternSnapshots(
+ [
+ snapshot({ id: 's-0-b', db: 0, pattern: 'b:*' }),
+ snapshot({ id: 's-1-a', db: 1, pattern: 'a:*' }),
+ snapshot({ id: 's-0-a', db: 0, pattern: 'a:*' }),
+ snapshot({ id: 's-1-b', db: 1, pattern: 'b:*' }),
+ ],
+ CONN,
+ );
+
+ const page = await storage.getKeyPatternSnapshots({ connectionId: CONN, limit: 2 });
+
+ expect(page.map((row) => [row.db, row.pattern])).toEqual([
+ [0, 'a:*'],
+ [0, 'b:*'],
+ ]);
+ });
+
+ it('keeps trends from different connections as separate series when connectionId is not provided', async () => {
+ await storage.saveKeyPatternSnapshots([snapshot({ id: 's0', keyCount: 10 })], CONN);
+ await storage.saveKeyPatternSnapshots([snapshot({ id: 's1', keyCount: 30 })], 'conn-b');
+
+ const unscoped = await storage.getKeyPatternTrends('user:*', 0, 2_000);
+
+ expect(unscoped.map((row) => row.keyCount).sort((a, b) => a - b)).toEqual([10, 30]);
+ });
+
it('lists the distinct databases that have analytics data', async () => {
await storage.saveKeyPatternSnapshots(
[snapshot({ id: 's0', db: 0 }), snapshot({ id: 's1', db: 2 }), snapshot({ id: 's2', db: 2, pattern: 'job:*' })],
diff --git a/apps/api/src/storage/adapters/postgres.adapter.ts b/apps/api/src/storage/adapters/postgres.adapter.ts
index e89627e2d..d214a228a 100644
--- a/apps/api/src/storage/adapters/postgres.adapter.ts
+++ b/apps/api/src/storage/adapters/postgres.adapter.ts
@@ -1420,6 +1420,7 @@ export class PostgresAdapter implements StoragePort, RawDatabaseHandleProvider {
CREATE INDEX IF NOT EXISTS idx_kps_pattern ON key_pattern_snapshots(pattern, timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_kps_pattern_timestamp ON key_pattern_snapshots(pattern, timestamp);
CREATE INDEX IF NOT EXISTS idx_kps_connection_id ON key_pattern_snapshots(connection_id);
+ CREATE INDEX IF NOT EXISTS idx_kps_connection_db ON key_pattern_snapshots(connection_id, db);
CREATE TABLE IF NOT EXISTS hot_key_stats (
id UUID PRIMARY KEY,
@@ -2764,7 +2765,7 @@ export class PostgresAdapter implements StoragePort, RawDatabaseHandleProvider {
`
SELECT * FROM key_pattern_snapshots
${whereClause}
- ORDER BY timestamp DESC
+ ORDER BY timestamp DESC, db ASC, pattern ASC
LIMIT $${paramIndex++} OFFSET $${paramIndex++}
`,
[...params, limit, offset],
@@ -2862,7 +2863,7 @@ export class PostgresAdapter implements StoragePort, RawDatabaseHandleProvider {
SUM(hot_key_count) as hot_key_count,
SUM(cold_key_count) as cold_key_count,
SUM(sampled_key_count) as sampled_key_count,
- SUM(avg_memory_bytes * sampled_key_count) as weighted_avg_memory
+ SUM(avg_memory_bytes::bigint * sampled_key_count) as weighted_avg_memory
FROM key_pattern_snapshots
WHERE ${patternWhere}
GROUP BY pattern
@@ -2942,7 +2943,10 @@ export class PostgresAdapter implements StoragePort, RawDatabaseHandleProvider {
}
// One collection writes one row per logical database; aggregate them per
- // timestamp so an unfiltered trend stays a single series.
+ // timestamp so an unfiltered trend stays a single series. When no
+ // connectionId is given, also group by connection_id so trends from
+ // different connections aren't merged into one series.
+ const groupBy = connectionId ? 'timestamp' : 'timestamp, connection_id';
const result = await this.pool.query(
`
SELECT
@@ -2952,7 +2956,7 @@ export class PostgresAdapter implements StoragePort, RawDatabaseHandleProvider {
SUM(stale_key_count) as stale_key_count
FROM key_pattern_snapshots
WHERE ${conditions.join(' AND ')}
- GROUP BY timestamp
+ GROUP BY ${groupBy}
ORDER BY timestamp ASC
`,
params,
diff --git a/apps/api/src/storage/adapters/sqlite.adapter.ts b/apps/api/src/storage/adapters/sqlite.adapter.ts
index 1465ca37e..25e29c77a 100644
--- a/apps/api/src/storage/adapters/sqlite.adapter.ts
+++ b/apps/api/src/storage/adapters/sqlite.adapter.ts
@@ -611,6 +611,11 @@ export class SqliteAdapter implements StoragePort, RawDatabaseHandleProvider {
if (!kpsInfo.some((col) => col.name === 'db')) {
this.db.exec('ALTER TABLE key_pattern_snapshots ADD COLUMN db INTEGER NOT NULL DEFAULT 0');
}
+ // Backs the 60s-polled SELECT DISTINCT db query used to populate the
+ // database selector; the column must exist before the index can be created.
+ this.db.exec(
+ 'CREATE INDEX IF NOT EXISTS idx_kps_connection_db ON key_pattern_snapshots(connection_id, db)',
+ );
// Migrate connection_id to all data tables for multi-database support
this.migrateConnectionId();
@@ -2665,7 +2670,7 @@ export class SqliteAdapter implements StoragePort, RawDatabaseHandleProvider {
const query = `
SELECT * FROM key_pattern_snapshots
${whereClause}
- ORDER BY timestamp DESC
+ ORDER BY timestamp DESC, db ASC, pattern ASC
LIMIT ? OFFSET ?
`;
@@ -2842,7 +2847,10 @@ export class SqliteAdapter implements StoragePort, RawDatabaseHandleProvider {
}
// One collection writes one row per logical database; aggregate them per
- // timestamp so an unfiltered trend stays a single series.
+ // timestamp so an unfiltered trend stays a single series. When no
+ // connectionId is given, also group by connection_id so trends from
+ // different connections aren't merged into one series.
+ const groupBy = connectionId ? 'timestamp' : 'timestamp, connection_id';
const query = `
SELECT
timestamp,
@@ -2851,7 +2859,7 @@ export class SqliteAdapter implements StoragePort, RawDatabaseHandleProvider {
SUM(stale_key_count) as stale_key_count
FROM key_pattern_snapshots
WHERE ${conditions.join(' AND ')}
- GROUP BY timestamp
+ GROUP BY ${groupBy}
ORDER BY timestamp ASC
`;
diff --git a/apps/web/src/hooks/useBaselineHotKeys.ts b/apps/web/src/hooks/useBaselineHotKeys.ts
index 45309a6cc..35ba3e5eb 100644
--- a/apps/web/src/hooks/useBaselineHotKeys.ts
+++ b/apps/web/src/hooks/useBaselineHotKeys.ts
@@ -5,6 +5,7 @@ interface UseBaselineHotKeysOptions {
connectionId?: string;
startTime?: number;
endTime?: number;
+ db?: number;
enabled?: boolean;
}
@@ -12,16 +13,18 @@ export function useBaselineHotKeys({
connectionId,
startTime,
endTime,
+ db,
enabled = true,
}: UseBaselineHotKeysOptions) {
return useQuery({
- queryKey: ['hot-keys-baseline', connectionId, startTime, endTime],
+ queryKey: ['hot-keys-baseline', connectionId, startTime, endTime, db],
queryFn: async () => {
const entries = await keyAnalyticsApi.getHotKeys({
limit: 50,
startTime,
endTime,
oldest: true,
+ db,
});
return entries.length > 0 ? entries : null;
},
diff --git a/apps/web/src/pages/KeyAnalytics.tsx b/apps/web/src/pages/KeyAnalytics.tsx
index 37416db29..b625323a2 100644
--- a/apps/web/src/pages/KeyAnalytics.tsx
+++ b/apps/web/src/pages/KeyAnalytics.tsx
@@ -124,11 +124,21 @@ export function KeyAnalytics() {
>('patterns');
const [sortField, setSortField] = useState('keyCount');
const [sortDirection, setSortDirection] = useState('desc');
- const [selectedPattern, setSelectedPattern] = useState(null);
+ const [selectedPattern, setSelectedPattern] = useState<{ db: number; pattern: string } | null>(
+ null,
+ );
// Logical database filter: 'all' aggregates across every scanned database.
const [rawSelectedDb, setSelectedDb] = useState('all');
const detailRef = useRef(null);
+ // Reset the db filter when the connection changes, so (a) the first fetches
+ // for a new connection don't go out with the old numeric filter while
+ // availableDbs is still loading for it, and (b) the filter doesn't silently
+ // snap back from 'all' if the remembered db happens to also exist there.
+ useEffect(() => {
+ setSelectedDb('all');
+ }, [currentConnection?.id]);
+
// Databases that have analytics data for this connection. The filter is only
// shown when there is more than one (or the single one is not db0).
const { data: availableDbs } = usePolling({
@@ -206,6 +216,7 @@ export function KeyAnalytics() {
connectionId: currentConnection?.id,
startTime: hotKeyStartTime,
endTime: hotKeyEndTime,
+ db: dbFilter,
enabled: activeTab === 'hot-keys' && isHotKeyTimeFiltered,
});
@@ -594,11 +605,12 @@ export function KeyAnalytics() {
setSelectedPattern(pattern.pattern)}
+ onClick={() => setSelectedPattern({ db: pattern.db ?? 0, pattern: pattern.pattern })}
>
{pattern.pattern}
@@ -643,7 +655,13 @@ export function KeyAnalytics() {
- Pattern Detail: {selectedPattern}
+ Pattern Detail:{' '}
+ {selectedPattern.pattern}
+ {(availableDbs?.length ?? 0) > 1 && (
+
+ db{selectedPattern.db}
+
+ )}
| |