perf(redis): avoid rebuilding key tree on detail load
This commit is contained in:
1 parent
d9723afceb
commit
c9c36a8453
4 files changed
+321
-43
No files matched your search
@@ -25,6 +25,8 @@ const mocks = vi.hoisted(() => ({
|
||||
redisExecuteCommand: vi.fn(),
|
||||
saveHistory: vi.fn(),
|
||||
canBuildRedisFuzzyTree: vi.fn((loadedKeyCount: number) => loadedKeyCount <= 200_000),
|
||||
createRedisKeyTreeIndex: vi.fn(),
|
||||
flattenVisibleRedisKeyTree: vi.fn(),
|
||||
toast: vi.fn(),
|
||||
updateRedisDbKeyStats: vi.fn(),
|
||||
listRedisCompletionCommandDocs: vi.fn(),
|
||||
@@ -33,6 +35,7 @@ const mocks = vi.hoisted(() => ({
|
||||
infiniteScroll: false,
|
||||
queryResultMaxRowsEnabled: true,
|
||||
queryResultMaxRows: 5000,
|
||||
loadedTtl: -1,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
@@ -56,7 +59,14 @@ vi.mock("@/lib/backend/api", () => ({
|
||||
|
||||
vi.mock("@/lib/redis/redisKeyTree", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/redis/redisKeyTree")>();
|
||||
return { ...actual, canBuildRedisFuzzyTree: mocks.canBuildRedisFuzzyTree };
|
||||
mocks.createRedisKeyTreeIndex.mockImplementation(actual.createRedisKeyTreeIndex);
|
||||
mocks.flattenVisibleRedisKeyTree.mockImplementation(actual.flattenVisibleRedisKeyTree);
|
||||
return {
|
||||
...actual,
|
||||
canBuildRedisFuzzyTree: mocks.canBuildRedisFuzzyTree,
|
||||
createRedisKeyTreeIndex: mocks.createRedisKeyTreeIndex,
|
||||
flattenVisibleRedisKeyTree: mocks.flattenVisibleRedisKeyTree,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
@@ -287,7 +297,26 @@ vi.mock("@/components/editor/DangerConfirmDialog.vue", async () => {
|
||||
|
||||
vi.mock("./RedisValueViewer.vue", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return { default: defineComponent({ setup: () => () => h("div") }) };
|
||||
return {
|
||||
default: defineComponent({
|
||||
props: { keyDisplay: String, keyRaw: String },
|
||||
emits: ["loaded"],
|
||||
setup(props, { emit }) {
|
||||
return () =>
|
||||
h("button", {
|
||||
"data-test-emit-key-loaded": "",
|
||||
onClick: () =>
|
||||
emit("loaded", {
|
||||
key_display: props.keyDisplay,
|
||||
key_raw: props.keyRaw,
|
||||
ttl: mocks.loadedTtl,
|
||||
redis_type: "hash",
|
||||
data: { kind: "hash", items: [] },
|
||||
}),
|
||||
});
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./RedisPubSubPanel.vue", async () => {
|
||||
@@ -379,6 +408,7 @@ function resetApiMocks() {
|
||||
mocks.infiniteScroll = false;
|
||||
mocks.queryResultMaxRowsEnabled = true;
|
||||
mocks.queryResultMaxRows = 5000;
|
||||
mocks.loadedTtl = -1;
|
||||
mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys: [], total_keys: 0 });
|
||||
mocks.redisGetValue.mockImplementation((_connectionId: string, _db: number, keyRaw: string) => Promise.resolve(redisValue(keyRaw)));
|
||||
mocks.redisSetString.mockResolvedValue(undefined);
|
||||
@@ -673,6 +703,80 @@ describe("RedisKeyBrowser scope changes", () => {
|
||||
});
|
||||
|
||||
describe("RedisKeyBrowser TTL list badges and no-expiry filter", () => {
|
||||
it("refreshes one key's metadata without rebuilding the loaded tree", async () => {
|
||||
mocks.redisScanKeysBatch.mockResolvedValue({
|
||||
cursor: 0,
|
||||
keys: [{ ...redisKeyInfo("string"), ttl: -1 }],
|
||||
total_keys: 1,
|
||||
});
|
||||
mountBrowser();
|
||||
await settle();
|
||||
|
||||
const keyCheckbox = requiredElement<HTMLInputElement>(`[data-redis-leaf="${KEY_RAW}"]`);
|
||||
const keyRow = keyCheckbox.parentElement?.parentElement?.parentElement;
|
||||
expect(keyRow).toBeInstanceOf(HTMLElement);
|
||||
keyRow?.click();
|
||||
await settle();
|
||||
|
||||
const buildsBeforeDetailLoaded = mocks.createRedisKeyTreeIndex.mock.calls.length;
|
||||
const flattensBeforeDetailLoaded = mocks.flattenVisibleRedisKeyTree.mock.calls.length;
|
||||
requiredElement<HTMLButtonElement>("[data-test-emit-key-loaded]").click();
|
||||
await settle();
|
||||
|
||||
expect(mocks.createRedisKeyTreeIndex).toHaveBeenCalledTimes(buildsBeforeDetailLoaded);
|
||||
expect(mocks.flattenVisibleRedisKeyTree).toHaveBeenCalledTimes(flattensBeforeDetailLoaded);
|
||||
expect(keyRow?.textContent).toContain("hash");
|
||||
expect(keyRow?.textContent).toContain("redis.noExpiry");
|
||||
});
|
||||
|
||||
it("refreshes no-expiry membership when detail metadata changes TTL", async () => {
|
||||
mocks.redisScanKeysBatch.mockResolvedValue({
|
||||
cursor: 0,
|
||||
keys: [redisKeyInfo("string")],
|
||||
total_keys: 1,
|
||||
});
|
||||
mountBrowser();
|
||||
await settle();
|
||||
|
||||
requiredElement<HTMLInputElement>(`[data-redis-leaf="${KEY_RAW}"]`).closest<HTMLElement>(".group")?.click();
|
||||
await settle();
|
||||
requiredElement<HTMLButtonElement>("[data-redis-no-expiry-filter]").click();
|
||||
await settle();
|
||||
expect(document.body.textContent).toContain("redis.noExpiryKeysEmpty");
|
||||
|
||||
requiredElement<HTMLButtonElement>("[data-test-emit-key-loaded]").click();
|
||||
await settle();
|
||||
|
||||
expect(document.querySelector(`[data-redis-leaf="${KEY_RAW}"]`)).not.toBeNull();
|
||||
expect(document.body.textContent).toContain("redis.noExpiry");
|
||||
});
|
||||
|
||||
it("starts the local TTL countdown when detail metadata gains an expiry", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
mocks.redisScanKeysBatch.mockResolvedValue({
|
||||
cursor: 0,
|
||||
keys: [{ ...redisKeyInfo("string"), ttl: -1 }],
|
||||
total_keys: 1,
|
||||
});
|
||||
mountBrowser();
|
||||
await settle();
|
||||
|
||||
requiredElement<HTMLInputElement>(`[data-redis-leaf="${KEY_RAW}"]`).closest<HTMLElement>(".group")?.click();
|
||||
await settle();
|
||||
mocks.loadedTtl = 2;
|
||||
requiredElement<HTMLButtonElement>("[data-test-emit-key-loaded]").click();
|
||||
await settle();
|
||||
expect(document.body.textContent).toContain("redis.ttlSecond");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
await settle();
|
||||
expect(document.body.textContent).toContain("redis.expired");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders TTL badges per row and filters rows to keys without expiry", async () => {
|
||||
mocks.redisScanKeysBatch.mockResolvedValue({
|
||||
cursor: 0,
|
||||
|
||||
@@ -37,6 +37,8 @@ import {
|
||||
flattenVisibleRedisKeyTree,
|
||||
redisKeyNameCopyText,
|
||||
redisKeyToFlatTreeRow,
|
||||
updateRedisKeyInfoMetadataByRaw,
|
||||
updateRedisKeyTreeLeafMetadata,
|
||||
type RedisKeyTreeGroupNode,
|
||||
type RedisKeyTreeIndex,
|
||||
type RedisKeyTreeNode,
|
||||
@@ -99,6 +101,7 @@ const redisExpiryTransport = {
|
||||
|
||||
const flatKeys = shallowRef<RedisKeyInfo[]>([]);
|
||||
const treeKeys = shallowRef<RedisKeyTreeNode[]>([]);
|
||||
const flatKeyByRaw = new Map<string, RedisKeyInfo>();
|
||||
const loading = ref(false);
|
||||
const loadingMore = ref(false);
|
||||
const searchPending = ref(false);
|
||||
@@ -124,6 +127,10 @@ const expandedGroupIds = ref<Set<string>>(new Set());
|
||||
const checkedKeys = ref<Set<string>>(new Set());
|
||||
/** Bumped when selection or tree counts change so virtualized rows re-evaluate state. */
|
||||
const selectionEpoch = ref(0);
|
||||
/** Bumped when mutable metadata changes; it must never invalidate visibleRows. */
|
||||
const keyMetadataEpoch = ref(0);
|
||||
/** Bumped only when a TTL refresh changes membership in the no-expiry projection. */
|
||||
const noExpiryProjectionEpoch = ref(0);
|
||||
const selectionAnchorRowId = ref<string | null>(null);
|
||||
const selectedGroupLeafCounts = shallowRef<Map<string, number>>(new Map());
|
||||
const deletingKeys = ref(false);
|
||||
@@ -252,7 +259,11 @@ const lastTotalKeys = ref(0);
|
||||
// TTL 为 -2 的行(fetch-all 链路未查询 TTL)不会出现在过滤结果里。
|
||||
const noExpiryOnly = ref(false);
|
||||
// 过滤后的平铺 key 列表:未开启过滤时与 flatKeys 完全一致,避免额外开销
|
||||
const filteredFlatKeys = computed(() => (noExpiryOnly.value ? flatKeys.value.filter((key) => key.ttl === -1) : flatKeys.value));
|
||||
const filteredFlatKeys = computed(() => {
|
||||
if (!noExpiryOnly.value) return flatKeys.value;
|
||||
void noExpiryProjectionEpoch.value;
|
||||
return flatKeys.value.filter((key) => key.ttl === -1);
|
||||
});
|
||||
// 过滤后的树:独立重建而不复用 treeIndex,避免污染后续 SCAN 增量合并的全量树基准;
|
||||
// 分组 id 只由 db+路径决定,与全量树一致,因此展开状态可直接复用
|
||||
const filteredTreeKeys = computed(() => {
|
||||
@@ -280,7 +291,10 @@ const keyCountText = computed(() => {
|
||||
const count = isSearchMode.value && hasMore.value && !isFetchingAll.value ? `${displayedKeyCount.value}+` : displayedKeyCount.value;
|
||||
return t("redis.keys", { count });
|
||||
});
|
||||
const selectedKey = computed(() => flatKeys.value.find((key) => key.key_raw === selectedKeyRaw.value) ?? null);
|
||||
const selectedKey = computed(() => {
|
||||
void keyMetadataEpoch.value;
|
||||
return selectedKeyRaw.value ? (flatKeyByRaw.get(selectedKeyRaw.value) ?? null) : null;
|
||||
});
|
||||
const dangerDetails = computed(() => {
|
||||
if (!pendingDanger.value) return "";
|
||||
if (pendingDanger.value.kind === "delete-keys") {
|
||||
@@ -374,6 +388,19 @@ watch(activeCreateKeyTypeHelp, () => {
|
||||
const visibleRows = computed(() => {
|
||||
return useFlatKeySearchRows.value ? filteredFlatKeys.value.map((key) => redisKeyToFlatTreeRow(key, props.db)) : flattenVisibleRedisKeyTree(filteredTreeKeys.value, expandedGroupIds.value);
|
||||
});
|
||||
|
||||
function redisRowKeyInfo(node: RedisKeyTreeNode): RedisKeyInfo | null {
|
||||
void keyMetadataEpoch.value;
|
||||
return node.kind === "leaf" ? (flatKeyByRaw.get(node.keyRaw) ?? null) : null;
|
||||
}
|
||||
|
||||
function redisRowKeyType(node: RedisKeyTreeNode): string {
|
||||
return redisRowKeyInfo(node)?.key_type ?? "";
|
||||
}
|
||||
|
||||
function redisRowTtl(node: RedisKeyTreeNode): number {
|
||||
return redisRowKeyInfo(node)?.ttl ?? -2;
|
||||
}
|
||||
// 列表行的 TTL 徽标文案:-1 表示永不过期,展示本地化文案;
|
||||
// 大于 0 时展示本地倒计时后的剩余时间,倒计时归零展示已过期;其余(-2 未查询)不显示
|
||||
function redisTtlBadgeText(ttl: number, displayTtl: number): string | null {
|
||||
@@ -390,6 +417,7 @@ function redisTtlBadgeClass(ttl: number, displayTtl: number): string {
|
||||
// 记录每个 key 的 TTL 被观测到的时刻(毫秒);本地倒计时 = 观测时的 TTL - 已流逝时间,
|
||||
// 与右侧详情面板同源(computeTtlCountdownValue),不需要额外的网络请求
|
||||
const ttlObservedAtByRaw = new Map<string, number>();
|
||||
const positiveTtlKeyRaws = new Set<string>();
|
||||
// 驱动列表行 TTL 倒计时的当前时刻,仅在存在需要倒计时的 key 时每秒更新
|
||||
const listTtlNowMs = ref(Date.now());
|
||||
let listTtlTimer: ReturnType<typeof setInterval> | null = null;
|
||||
@@ -399,11 +427,25 @@ function recordKeyTtlObservedAt(key: RedisKeyInfo) {
|
||||
const ttl = key.ttl ?? -2;
|
||||
if (ttl > 0) {
|
||||
ttlObservedAtByRaw.set(key.key_raw, Date.now());
|
||||
positiveTtlKeyRaws.add(key.key_raw);
|
||||
} else {
|
||||
ttlObservedAtByRaw.delete(key.key_raw);
|
||||
positiveTtlKeyRaws.delete(key.key_raw);
|
||||
}
|
||||
}
|
||||
|
||||
function appendFlatKeyRecords(keys: readonly RedisKeyInfo[]) {
|
||||
if (keys.length === 0) return;
|
||||
for (const key of keys) flatKeyByRaw.set(key.key_raw, key);
|
||||
flatKeys.value = [...flatKeys.value, ...keys];
|
||||
}
|
||||
|
||||
function replaceFlatKeyRecords(keys: RedisKeyInfo[]) {
|
||||
flatKeyByRaw.clear();
|
||||
for (const key of keys) flatKeyByRaw.set(key.key_raw, key);
|
||||
flatKeys.value = keys;
|
||||
}
|
||||
|
||||
// 列表行展示的 TTL:正 TTL 按观测时刻到当前时刻的流逝本地递减;-1/-2 原样透传
|
||||
function redisRowDisplayTtl(ttl: number, keyRaw: string): number {
|
||||
if (ttl <= 0) return ttl;
|
||||
@@ -413,7 +455,7 @@ function redisRowDisplayTtl(ttl: number, keyRaw: string): number {
|
||||
|
||||
// 按需启停倒计时定时器:只在组件激活且存在正 TTL 的 key 时运行,避免空转
|
||||
function syncListTtlTimer() {
|
||||
const needed = redisBrowserIsActive && flatKeys.value.some((key) => (key.ttl ?? -2) > 0);
|
||||
const needed = redisBrowserIsActive && positiveTtlKeyRaws.size > 0;
|
||||
if (needed && !listTtlTimer) {
|
||||
listTtlNowMs.value = Date.now();
|
||||
listTtlTimer = setInterval(() => {
|
||||
@@ -597,7 +639,7 @@ function rebuildTree(expandAll = false) {
|
||||
expandedGroupIds.value = nextExpanded;
|
||||
refreshSelectedGroupLeafCounts();
|
||||
|
||||
if (selectedKeyRaw.value && !flatKeys.value.some((key) => key.key_raw === selectedKeyRaw.value)) {
|
||||
if (selectedKeyRaw.value && !flatKeyByRaw.has(selectedKeyRaw.value)) {
|
||||
selectedKeyRaw.value = null;
|
||||
}
|
||||
}
|
||||
@@ -704,7 +746,7 @@ function appendScanResult(result: RedisScanResult, options: { updateTree?: boole
|
||||
if (options.buffer) {
|
||||
for (const key of newKeys) options.buffer.push(key);
|
||||
} else if (newKeys.length > 0) {
|
||||
flatKeys.value = [...flatKeys.value, ...newKeys];
|
||||
appendFlatKeyRecords(newKeys);
|
||||
}
|
||||
const loadedCount = flatKeys.value.length + (options.buffer?.length ?? 0);
|
||||
scanCursor.value = result.cursor;
|
||||
@@ -766,8 +808,9 @@ async function loadKeys() {
|
||||
loading.value = true;
|
||||
loadedKeyRaws.clear();
|
||||
ttlObservedAtByRaw.clear();
|
||||
positiveTtlKeyRaws.clear();
|
||||
subtreeFilledGroupIds.clear();
|
||||
flatKeys.value = [];
|
||||
replaceFlatKeyRecords([]);
|
||||
treeKeys.value = [];
|
||||
treeIndex = null;
|
||||
selectedKeyRaw.value = null;
|
||||
@@ -918,7 +961,7 @@ async function fetchAll(): Promise<boolean> {
|
||||
completed = requestId === searchRequestId && !fetchAllStopRequested.value && !hasMore.value;
|
||||
} finally {
|
||||
if (requestId === searchRequestId) {
|
||||
if (bufferedKeys.length > 0) flatKeys.value = [...flatKeys.value, ...bufferedKeys];
|
||||
appendFlatKeyRecords(bufferedKeys);
|
||||
if (changed) {
|
||||
if (useFlatKeySearchRows.value) {
|
||||
treeKeys.value = [];
|
||||
@@ -953,7 +996,7 @@ function shouldFillGroupSubtree(): boolean {
|
||||
function mergeScannedKeys(newKeys: RedisKeyInfo[]) {
|
||||
if (newKeys.length === 0) return;
|
||||
for (const key of newKeys) recordKeyTtlObservedAt(key);
|
||||
flatKeys.value = [...flatKeys.value, ...newKeys];
|
||||
appendFlatKeyRecords(newKeys);
|
||||
mergeTree(newKeys);
|
||||
connectionStore.updateRedisDbKeyStats(props.connectionId, props.db, {
|
||||
loaded: isSearchMode.value ? undefined : flatKeys.value.length,
|
||||
@@ -1014,10 +1057,11 @@ function onRowClick(node: RedisKeyTreeNode, event?: MouseEvent) {
|
||||
}
|
||||
|
||||
function removeKnownKey(keyRaw: string) {
|
||||
if (!flatKeys.value.some((key) => key.key_raw === keyRaw)) return;
|
||||
if (!flatKeyByRaw.has(keyRaw)) return;
|
||||
loadedKeyRaws.delete(keyRaw);
|
||||
ttlObservedAtByRaw.delete(keyRaw);
|
||||
flatKeys.value = flatKeys.value.filter((key) => key.key_raw !== keyRaw);
|
||||
positiveTtlKeyRaws.delete(keyRaw);
|
||||
replaceFlatKeyRecords(flatKeys.value.filter((key) => key.key_raw !== keyRaw));
|
||||
if (selectedKeyRaw.value === keyRaw) selectedKeyRaw.value = null;
|
||||
if (useFlatKeySearchRows.value) {
|
||||
treeKeys.value = [];
|
||||
@@ -1043,7 +1087,7 @@ function onKeyRenamed(oldKeyRaw: string, newKeyRaw: string, newKeyDisplay: strin
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = flatKeys.value.find((key) => key.key_raw === oldKeyRaw);
|
||||
const previous = flatKeyByRaw.get(oldKeyRaw);
|
||||
if (!previous) {
|
||||
void loadKeys();
|
||||
return;
|
||||
@@ -1055,7 +1099,8 @@ function onKeyRenamed(oldKeyRaw: string, newKeyRaw: string, newKeyDisplay: strin
|
||||
const observedAt = ttlObservedAtByRaw.get(oldKeyRaw);
|
||||
ttlObservedAtByRaw.delete(oldKeyRaw);
|
||||
if (observedAt !== undefined) ttlObservedAtByRaw.set(newKeyRaw, observedAt);
|
||||
flatKeys.value = flatKeys.value.map((key) => (key.key_raw === oldKeyRaw ? { ...key, key_raw: newKeyRaw, key_display: newKeyDisplay } : key));
|
||||
if (positiveTtlKeyRaws.delete(oldKeyRaw)) positiveTtlKeyRaws.add(newKeyRaw);
|
||||
replaceFlatKeyRecords(flatKeys.value.map((key) => (key.key_raw === oldKeyRaw ? { ...key, key_raw: newKeyRaw, key_display: newKeyDisplay } : key)));
|
||||
if (selectedKeyRaw.value === oldKeyRaw) selectedKeyRaw.value = newKeyRaw;
|
||||
if (checkedKeys.value.has(oldKeyRaw)) {
|
||||
const nextCheckedKeys = new Set(checkedKeys.value);
|
||||
@@ -1089,19 +1134,15 @@ function onKeyLoaded(value: RedisValue) {
|
||||
return;
|
||||
}
|
||||
const keyInfo = redisValueToKeyInfo(value);
|
||||
const existingIndex = flatKeys.value.findIndex((key) => key.key_raw === keyInfo.key_raw);
|
||||
if (existingIndex < 0) return;
|
||||
const previousTtl = flatKeyByRaw.get(keyInfo.key_raw)?.ttl ?? -2;
|
||||
if (!updateRedisKeyInfoMetadataByRaw(flatKeyByRaw, keyInfo)) return;
|
||||
// 详情面板回写了最新的 TTL,同步刷新观测时刻,保证两侧倒计时一致
|
||||
recordKeyTtlObservedAt(keyInfo);
|
||||
flatKeys.value = flatKeys.value.map((key, index) => (index === existingIndex ? keyInfo : key));
|
||||
loadedKeyRaws.add(keyInfo.key_raw);
|
||||
if (useFlatKeySearchRows.value) {
|
||||
treeKeys.value = [];
|
||||
treeIndex = null;
|
||||
refreshSelectedGroupLeafCounts();
|
||||
} else {
|
||||
rebuildTree(false);
|
||||
}
|
||||
if (treeIndex) updateRedisKeyTreeLeafMetadata(treeIndex, keyInfo);
|
||||
if ((previousTtl === -1) !== (keyInfo.ttl === -1)) noExpiryProjectionEpoch.value++;
|
||||
keyMetadataEpoch.value++;
|
||||
syncListTtlTimer();
|
||||
}
|
||||
|
||||
function requestBatchDelete() {
|
||||
@@ -1202,7 +1243,8 @@ function resetLoadedKeys() {
|
||||
fetchAllLoadedCount.value = 0;
|
||||
loadedKeyRaws.clear();
|
||||
ttlObservedAtByRaw.clear();
|
||||
flatKeys.value = [];
|
||||
positiveTtlKeyRaws.clear();
|
||||
replaceFlatKeyRecords([]);
|
||||
treeKeys.value = [];
|
||||
treeIndex = null;
|
||||
selectedKeyRaw.value = null;
|
||||
@@ -1230,8 +1272,9 @@ async function deleteKeyRaws(keys: string[]) {
|
||||
for (const key of deleted) {
|
||||
loadedKeyRaws.delete(key);
|
||||
ttlObservedAtByRaw.delete(key);
|
||||
positiveTtlKeyRaws.delete(key);
|
||||
}
|
||||
flatKeys.value = flatKeys.value.filter((key) => !deleted.has(key.key_raw));
|
||||
replaceFlatKeyRecords(flatKeys.value.filter((key) => !deleted.has(key.key_raw)));
|
||||
if (selectedKeyRaw.value && deleted.has(selectedKeyRaw.value)) {
|
||||
selectedKeyRaw.value = null;
|
||||
}
|
||||
@@ -1456,20 +1499,20 @@ function upsertCreatedKey(value: RedisValue) {
|
||||
size: redisValueSize(value),
|
||||
value_preview: redisValuePreview(value),
|
||||
};
|
||||
const existingIndex = flatKeys.value.findIndex((key) => key.key_raw === keyInfo.key_raw);
|
||||
const existing = flatKeyByRaw.get(keyInfo.key_raw);
|
||||
// 新建 key 携带的 TTL 以当前时刻为观测起点
|
||||
recordKeyTtlObservedAt(keyInfo);
|
||||
if (existingIndex >= 0) {
|
||||
flatKeys.value = flatKeys.value.map((key, index) => (index === existingIndex ? keyInfo : key));
|
||||
if (existing) {
|
||||
replaceFlatKeyRecords(flatKeys.value.map((key) => (key.key_raw === keyInfo.key_raw ? keyInfo : key)));
|
||||
} else {
|
||||
flatKeys.value = [keyInfo, ...flatKeys.value];
|
||||
replaceFlatKeyRecords([keyInfo, ...flatKeys.value]);
|
||||
}
|
||||
loadedKeyRaws.add(keyInfo.key_raw);
|
||||
selectedKeyRaw.value = keyInfo.key_raw;
|
||||
rebuildTree(isSearchMode.value);
|
||||
connectionStore.updateRedisDbKeyStats(props.connectionId, props.db, {
|
||||
loaded: isSearchMode.value ? undefined : flatKeys.value.length,
|
||||
totalDelta: existingIndex >= 0 ? 0 : 1,
|
||||
totalDelta: existing ? 0 : 1,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2435,14 +2478,14 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center justify-end gap-1">
|
||||
<Badge v-if="row.node.kind === 'leaf' && row.node.keyType" variant="outline" class="text-xs px-1.5 py-0" :class="typeColor(row.node.keyType)">{{ row.node.keyType }}</Badge>
|
||||
<Badge v-if="row.node.kind === 'leaf' && redisRowKeyType(row.node)" variant="outline" class="text-xs px-1.5 py-0" :class="typeColor(redisRowKeyType(row.node))">{{ redisRowKeyType(row.node) }}</Badge>
|
||||
<!-- TTL 徽标:与类型徽标保持一致的胶囊样式,永不过期为琥珀色、临近过期/已过期为红色警示 -->
|
||||
<span
|
||||
v-if="row.node.kind === 'leaf' && redisTtlBadgeText(row.node.ttl, redisRowDisplayTtl(row.node.ttl, row.node.keyRaw))"
|
||||
v-if="row.node.kind === 'leaf' && redisTtlBadgeText(redisRowTtl(row.node), redisRowDisplayTtl(redisRowTtl(row.node), row.node.keyRaw))"
|
||||
class="inline-flex shrink-0 items-center whitespace-nowrap rounded border px-1.5 py-0.5 text-[11px] leading-none"
|
||||
:class="redisTtlBadgeClass(row.node.ttl, redisRowDisplayTtl(row.node.ttl, row.node.keyRaw))"
|
||||
:title="row.node.ttl === -1 ? t('redis.noExpiry') : t('redis.ttlCountdownTitle')"
|
||||
>{{ redisTtlBadgeText(row.node.ttl, redisRowDisplayTtl(row.node.ttl, row.node.keyRaw)) }}</span
|
||||
:class="redisTtlBadgeClass(redisRowTtl(row.node), redisRowDisplayTtl(redisRowTtl(row.node), row.node.keyRaw))"
|
||||
:title="redisRowTtl(row.node) === -1 ? t('redis.noExpiry') : t('redis.ttlCountdownTitle')"
|
||||
>{{ redisTtlBadgeText(redisRowTtl(row.node), redisRowDisplayTtl(redisRowTtl(row.node), row.node.keyRaw)) }}</span
|
||||
>
|
||||
<Button v-if="row.node.kind === 'group' && !isFuzzyHierarchyView" variant="ghost" size="icon" class="h-5 w-5 shrink-0 text-destructive opacity-0 group-hover:opacity-100" :title="t('redis.deleteGroup')" :disabled="selectionBusy" @click="requestGroupDelete(row.node, $event)">
|
||||
<Trash2 class="h-3 w-3" />
|
||||
|
||||
@@ -43,7 +43,7 @@ export interface RedisKeyTreeRow {
|
||||
export interface RedisKeyTreeIndex {
|
||||
root: RedisKeyTreeNode[];
|
||||
groupById: Map<string, RedisKeyTreeGroupNode>;
|
||||
keyRaws: Set<string>;
|
||||
leafByKeyRaw: Map<string, RedisKeyTreeLeafNode>;
|
||||
ancestorGroupIdsByKeyRaw: Map<string, readonly string[]>;
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ export function createRedisKeyTreeIndex(keys: readonly RedisKeyInfo[], db: numbe
|
||||
const index: RedisKeyTreeIndex = {
|
||||
root: [],
|
||||
groupById: new Map(),
|
||||
keyRaws: new Set(),
|
||||
leafByKeyRaw: new Map(),
|
||||
ancestorGroupIdsByKeyRaw: new Map(),
|
||||
};
|
||||
appendRedisKeysToTreeIndex(index, keys, db, separator);
|
||||
@@ -122,7 +122,7 @@ export function appendRedisKeysToTreeIndex(index: RedisKeyTreeIndex, keys: reado
|
||||
const addedGroupIds = new Set<string>();
|
||||
|
||||
for (const key of keys) {
|
||||
if (index.keyRaws.has(key.key_raw)) continue;
|
||||
if (index.leafByKeyRaw.has(key.key_raw)) continue;
|
||||
|
||||
const pathSegments = separator ? key.key_display.split(separator) : [key.key_display];
|
||||
const ancestorGroupIds: string[] = [];
|
||||
@@ -154,7 +154,7 @@ export function appendRedisKeysToTreeIndex(index: RedisKeyTreeIndex, keys: reado
|
||||
}
|
||||
}
|
||||
|
||||
currentLevel.push({
|
||||
const leaf: RedisKeyTreeLeafNode = {
|
||||
kind: "leaf",
|
||||
id: buildLeafId(db, key.key_raw),
|
||||
label: pathSegments[pathSegments.length - 1],
|
||||
@@ -166,9 +166,10 @@ export function appendRedisKeysToTreeIndex(index: RedisKeyTreeIndex, keys: reado
|
||||
size: key.size ?? 0,
|
||||
valuePreview: key.value_preview ?? "",
|
||||
pathSegments,
|
||||
});
|
||||
};
|
||||
currentLevel.push(leaf);
|
||||
touchedLevels.add(currentLevel);
|
||||
index.keyRaws.add(key.key_raw);
|
||||
index.leafByKeyRaw.set(key.key_raw, leaf);
|
||||
index.ancestorGroupIdsByKeyRaw.set(key.key_raw, ancestorGroupIds);
|
||||
}
|
||||
|
||||
@@ -176,6 +177,37 @@ export function appendRedisKeysToTreeIndex(index: RedisKeyTreeIndex, keys: reado
|
||||
return { addedGroupIds };
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes metadata for an existing tree leaf without changing namespace
|
||||
* identity, hierarchy, ordering, or aggregate counts.
|
||||
*/
|
||||
export function updateRedisKeyTreeLeafMetadata(index: RedisKeyTreeIndex, key: RedisKeyInfo): boolean {
|
||||
const leaf = index.leafByKeyRaw.get(key.key_raw);
|
||||
if (!leaf) return false;
|
||||
|
||||
leaf.keyType = key.key_type ?? "";
|
||||
leaf.ttl = key.ttl ?? -2;
|
||||
leaf.size = key.size ?? 0;
|
||||
leaf.valuePreview = key.value_preview ?? "";
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes metadata on the canonical flat-list record through its identity
|
||||
* index. The existing object is retained so large shallow collections do not
|
||||
* need to be copied for a single-key refresh.
|
||||
*/
|
||||
export function updateRedisKeyInfoMetadataByRaw(keyByRaw: ReadonlyMap<string, RedisKeyInfo>, key: RedisKeyInfo): boolean {
|
||||
const existing = keyByRaw.get(key.key_raw);
|
||||
if (!existing) return false;
|
||||
|
||||
existing.key_type = key.key_type;
|
||||
existing.ttl = key.ttl;
|
||||
existing.size = key.size;
|
||||
existing.value_preview = key.value_preview;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function buildRedisKeyTree(keys: RedisKeyInfo[], db: number, separator = ":"): RedisKeyTreeNode[] {
|
||||
return createRedisKeyTreeIndex(keys, db, separator).root;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user