RSS Git Download  Clone
Raw Blame History 18kB 529 lines
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<string>();
    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<string>();
            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<string>();
            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<void> {
        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<void> {
        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<void> {
        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<void> {
        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.
            }
        });
    }
}