import { Component, Input, Inject, OnInit, OnDestroy, NgZone, ElementRef, ViewChild, ChangeDetectorRef, ChangeDetectionStrategy, ViewEncapsulation, CUSTOM_ELEMENTS_SCHEMA, effect } from '@angular/core'; import { CommonModule } from '@angular/common'; import { CdkVirtualScrollViewport, ScrollingModule } from '@angular/cdk/scrolling'; import { MatTooltipModule } from '@angular/material/tooltip'; import { I18nService } from '../../services/i18n.service'; import { CommonService } from '../../services/common.service'; import { ThemeService } from '../../services/theme.service'; import { SocketService } from '../../services/socket.service'; import { KeyNewOrSetDialogService } from '../../dialogs/key-new-or-set-dialog.service'; import { NavigationService } from '../../services/navigation.service'; import { MainCommandService } from '../../services/main-command.service'; import { TreeBuilderService } from '../../services/tree-builder.service'; import { RedisStateService } from '../../services/redis-state.service'; import { SettingsService } from '../../services/settings.service'; require('./database-tree.component.scss'); export interface FlatTreeNode { label: string; key: string; level: number; expandable: boolean; type: 'folder' | 'element'; childCount: number; keysInfo?: { type: string; length: number; ttl?: number }; // Reference to the original hierarchical node (for expandedNodes sync) _sourceNode?: any; } @Component({ selector: 'p3xr-database-tree', standalone: true, imports: [ CommonModule, ScrollingModule, MatTooltipModule, ], schemas: [CUSTOM_ELEMENTS_SCHEMA], templateUrl: './database-tree.component.html', encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) export class DatabaseTreeComponent implements OnInit, OnDestroy { @Input() p3xrResize: any; @Input() p3xrMainRef: any; @ViewChild(CdkVirtualScrollViewport) private viewport?: CdkVirtualScrollViewport; dataSource: FlatTreeNode[] = []; isEnabled = false; isReadonly = false; divider = ':'; readonly strings; private expandedKeys = new Set(); private expandedNodeObjects: any[] = []; private hierarchicalNodes: any[] = []; private readonly unsubs: Array<() => void> = []; constructor( @Inject(NgZone) private readonly ngZone: NgZone, @Inject(ElementRef) private readonly elementRef: ElementRef, @Inject(I18nService) private readonly i18n: I18nService, @Inject(CommonService) private readonly common: CommonService, @Inject(ThemeService) private readonly theme: ThemeService, @Inject(SocketService) private readonly socket: SocketService, @Inject(KeyNewOrSetDialogService) private readonly keyNewOrSetDialog: KeyNewOrSetDialogService, @Inject(NavigationService) private readonly nav: NavigationService, @Inject(MainCommandService) private readonly cmd: MainCommandService, @Inject(ChangeDetectorRef) private readonly cdr: ChangeDetectorRef, @Inject(TreeBuilderService) private readonly treeBuilder: TreeBuilderService, @Inject(RedisStateService) private readonly state: RedisStateService, @Inject(SettingsService) private readonly settingsService: SettingsService, ) { this.strings = this.i18n.strings; effect(() => { this.i18n.currentLang(); this.cdr.markForCheck(); }); } ngOnInit(): void { this.syncGlobalState(); this.attachWindowFocusListener(); this.startPolling(); this.startTtlRepaint(); // Subscribe to MainCommandService events const subDelete = this.cmd.keyDelete$.subscribe((arg) => { this.ngZone.run(() => this.deleteKey(arg.event, arg.key)); }); this.unsubs.push(() => subDelete.unsubscribe()); const subRename = this.cmd.keyRename$.subscribe((arg) => { this.ngZone.run(() => this.renameKey(arg.event, arg.key)); }); this.unsubs.push(() => subRename.unsubscribe()); const subKeyNew = this.cmd.keyNew$.subscribe((arg) => { this.ngZone.run(() => this.addKey(arg.event, arg.node ? { ...arg, _sourceNode: arg.node } as any : arg as any)); }); this.unsubs.push(() => subKeyNew.unsubscribe()); const subTreeEnabled = this.cmd.treeControlEnabled$.subscribe((enabled) => { this.ngZone.run(() => { this.isEnabled = enabled; }); }); this.unsubs.push(() => subTreeEnabled.unsubscribe()); const subTreeRefresh = this.cmd.treeRefresh$.subscribe(() => { this.ngZone.run(() => { this.syncGlobalState(); this.rebuildTree(); }); }); this.unsubs.push(() => subTreeRefresh.unsubscribe()); const subExpand = this.common.treeExpandAll$.subscribe(() => this.ngZone.run(() => { const allFolderKeys = new Set(); const collect = (nodes: any[]) => { for (const node of nodes) { if (node.type === 'folder') { allFolderKeys.add(node.key); collect(node.children ?? []); } } }; collect(this.hierarchicalNodes); this.expandedKeys = allFolderKeys; this.flattenVisibleNodes(); this.syncExpandedNodesToGlobal(); this.cdr.markForCheck(); })); this.unsubs.push(() => subExpand.unsubscribe()); const subCollapse = this.common.treeCollapseAll$.subscribe(() => this.ngZone.run(() => { this.expandedKeys = new Set(); this.flattenVisibleNodes(); this.syncExpandedNodesToGlobal(); this.cdr.markForCheck(); })); this.unsubs.push(() => subCollapse.unsubscribe()); const subExpandLevel = this.common.treeExpandToLevel$.subscribe((level: number) => this.ngZone.run(() => { const keys = new Set(); const collect = (nodes: any[], depth: number) => { for (const node of nodes) { if (node.type === 'folder') { if (depth < level) { keys.add(node.key); } collect(node.children ?? [], depth + 1); } } }; collect(this.hierarchicalNodes, 0); this.expandedKeys = keys; this.flattenVisibleNodes(); this.syncExpandedNodesToGlobal(); this.cdr.markForCheck(); })); this.unsubs.push(() => subExpandLevel.unsubscribe()); setTimeout(() => { this.isEnabled = true; this.cdr.markForCheck(); }, 50); } ngOnDestroy(): void { this.unsubs.forEach(fn => fn()); } // --- TTL (computed on-the-fly from fetchedAt, single 30s repaint) --- private ttlRepaintTimer: any; private startTtlRepaint(): void { const tick = () => { this.cdr.markForCheck(); let minTtl = Infinity; let hasExpired = false; for (const node of this.dataSource) { if (node.type === 'folder') continue; const serverTtl = node.keysInfo?.ttl; if (!serverTtl || serverTtl <= 0) continue; const remaining = this.getRemainingTtl(node); if (remaining <= 0) { hasExpired = true; } else if (remaining < minTtl) { minTtl = remaining; } } if (hasExpired) { this.cmd.refresh(); // Retry soon in case refresh was throttled this.ttlRepaintTimer = setTimeout(tick, 3000); return; } let interval: number; if (minTtl <= 30) { interval = 1000; } else if (minTtl <= 300) { interval = 5000; } else { interval = 30000; } this.ttlRepaintTimer = setTimeout(tick, interval); }; this.ttlRepaintTimer = setTimeout(tick, 30000); this.unsubs.push(() => clearTimeout(this.ttlRepaintTimer)); } getRemainingTtl(node: FlatTreeNode): number { const ttl = node.keysInfo?.ttl; if (!ttl || ttl <= 0) return -1; const fetchedAt = this.state.keysInfoFetchedAt() ?? Date.now(); const elapsed = Math.floor((Date.now() - fetchedAt) / 1000); const remaining = ttl - elapsed; return remaining > 0 ? remaining : -1; } formatTtl(node: FlatTreeNode): string { const remaining = this.getRemainingTtl(node); if (remaining <= 0) return ''; const humanizeDuration = require('humanize-duration'); const hdOpts = this.settingsService.getHumanizeDurationOptions(); return humanizeDuration(remaining * 1000, { ...hdOpts, largest: 2, round: true, delimiter: ' ', }); } getTtlClass(node: FlatTreeNode): string { const remaining = this.getRemainingTtl(node); if (remaining <= 0) return ''; if (remaining < 30) return 'p3xr-tree-ttl-red p3xr-tree-ttl-pulse'; if (remaining < 300) return 'p3xr-tree-ttl-red'; if (remaining < 3600) return 'p3xr-tree-ttl-yellow'; return 'p3xr-tree-ttl-green'; } // --- Tree data --- trackByKey(_index: number, node: FlatTreeNode): string { return node.key; } isExpanded(node: FlatTreeNode): boolean { return this.expandedKeys.has(node.key); } toggleExpand(node: FlatTreeNode): void { if (this.expandedKeys.has(node.key)) { this.expandedKeys.delete(node.key); } else { this.expandedKeys.add(node.key); } this.flattenVisibleNodes(); this.syncExpandedNodesToGlobal(); } // --- Node actions --- selectNode(node: FlatTreeNode): void { this.navigateTo('database.key', { key: node.key, }); } async deleteKey(event: Event, key: string): Promise { try { event.preventDefault(); event.stopPropagation(); await this.common.confirm({ message: this.i18n.strings().confirm.deleteKey, }); await this.socket.request({ action: 'delete', payload: { key }, }); if (typeof window['gtag'] === 'function') { window['gtag']('config', this.settingsService.googleAnalytics, { page_path: '/delete' }); } this.navigateTo('database.statistics'); this.common.toast(this.i18n.strings().status.deletedKey({ key })); await this.cmd.refresh(); this.rebuildTree(); } catch (e) { this.common.generalHandleError(e); } } async renameKey(event: Event, key: string): Promise { try { event?.stopPropagation?.(); const newKey = await this.common.prompt({ title: this.i18n.strings().confirm.rename.title, placeholder: this.i18n.strings().confirm.rename.placeholder, initialValue: key, ok: this.i18n.strings().intention.rename, cancel: this.i18n.strings().intention.cancel, }); await this.socket.request({ action: 'rename', payload: { key, keyNew: newKey }, }); if (typeof window['gtag'] === 'function') { window['gtag']('config', this.settingsService.googleAnalytics, { page_path: '/rename' }); } this.navigateTo('database.key', { key: newKey, }); this.common.toast(this.i18n.strings().status.renamedKey); await this.cmd.refresh(); this.rebuildTree(); } catch (e) { this.common.generalHandleError(e); } } async deleteTree(event: Event, node: FlatTreeNode): Promise { try { event.stopPropagation(); await this.common.confirm({ message: this.i18n.strings().confirm.deleteAllKeys({ key: node.key }), }); const divider = this.settingsService.redisTreeDivider(); await this.socket.request({ action: 'key-del-tree', payload: { key: node.key, redisTreeDivider: divider, }, }); this.common.toast(this.i18n.strings().status.treeDeleted({ key: node.key })); // If currently viewing a key under the deleted tree, go to statistics const currentPath = location.pathname; if (currentPath.startsWith('/database/key/')) { const currentKey = decodeURIComponent(currentPath.slice('/database/key/'.length).replace(/~/g, '%')); if (currentKey.startsWith(node.key + divider)) { this.navigateTo('database.statistics'); } } await this.cmd.refresh(); this.rebuildTree(); } catch (e) { this.common.generalHandleError(e); } } async addKey(event: Event, node: FlatTreeNode): Promise { try { event.stopPropagation(); const response = await this.keyNewOrSetDialog.show({ type: 'add', $event: event, node: node._sourceNode ?? { key: node.key }, }); await this.cmd.refresh(); this.rebuildTree(); this.navigateTo('database.key', { key: response.key, }); } catch (e) { this.common.generalHandleError(e); } } // --- Tooltips --- extractNodeTooltip(node: FlatTreeNode): string { if (node.type !== 'folder' && node.keysInfo) { const strings = this.i18n.strings(); return (globalThis as any).htmlEncode((strings.redisTypes?.[node.keysInfo.type] ?? node.keysInfo.type) + ' - ' + node.key); } return (globalThis as any).htmlEncode(node.key); } deleteTreeTooltip(node: FlatTreeNode): string { return this.i18n.strings().confirm?.deleteAllKeys?.({ key: node.key }) ?? ''; } // --- Tree rebuild --- private rebuildTree(): void { this.divider = this.settingsService.redisTreeDivider() ?? ':'; this.isReadonly = this.state.connection()?.readonly === true; const keys: string[] = this.state.paginatedKeys() ?? []; const keysInfo: any = this.state.keysInfo() ?? {}; this.treeBuilder.keysToTreeControl({ keys, divider: this.divider, keysInfo, }).then(({ nodes }) => { this.hierarchicalNodes = nodes; this.flattenVisibleNodes(); this.requestViewRefresh(); }); } private flattenVisibleNodes(): void { const result: FlatTreeNode[] = []; const flatten = (nodes: any[], level: number) => { for (const node of nodes) { result.push({ label: node.label, key: node.key, level, expandable: node.type === 'folder', type: node.type, childCount: node.childCount ?? 0, keysInfo: node.keysInfo, _sourceNode: node, }); if (node.type === 'folder' && this.expandedKeys.has(node.key) && node.children?.length > 0) { flatten(node.children, level + 1); } } }; flatten(this.hierarchicalNodes, 0); this.dataSource = result; } private syncExpandedNodesToGlobal(): void { // Build array of node objects matching the expanded keys const expandedNodeObjects: any[] = []; const collectExpanded = (nodes: any[]) => { for (const node of nodes) { if (node.type === 'folder' && this.expandedKeys.has(node.key)) { expandedNodeObjects.push(node); } if (node.children?.length > 0) { collectExpanded(node.children); } } }; collectExpanded(this.hierarchicalNodes); // Keep expanded nodes locally this.expandedNodeObjects = expandedNodeObjects; } private syncGlobalState(): void { this.divider = this.settingsService.redisTreeDivider() ?? ':'; this.isReadonly = this.state.connection()?.readonly === true; } // --- Polling for change detection --- private startPolling(): void { let lastSnapshot = ''; const id = setInterval(() => { const snapshot = JSON.stringify({ keysLength: this.state.paginatedKeys()?.length, page: this.state.page(), divider: this.settingsService.redisTreeDivider(), readonly: this.state.connection()?.readonly, }); if (snapshot !== lastSnapshot) { lastSnapshot = snapshot; this.ngZone.run(() => { this.syncGlobalState(); this.rebuildTree(); }); } }, 300); this.unsubs.push(() => clearInterval(id)); // Initial build this.ngZone.run(() => this.rebuildTree()); } private attachWindowFocusListener(): void { const focusListener = () => { if (this.isEnabled) { this.ngZone.run(() => { this.isEnabled = false; setTimeout(() => { this.isEnabled = true; this.rebuildTree(); }); }); } }; window.addEventListener('focus', focusListener); this.unsubs.push(() => window.removeEventListener('focus', focusListener)); } // --- Navigation --- private navigateTo(state: string, params?: any): void { this.nav.navigateTo(state, params); } private requestViewRefresh(): void { setTimeout(() => { try { this.cdr.detectChanges(); this.viewport?.checkViewportSize(); } catch { // Ignore late refreshes during teardown. } }); } }