RSS Git Download  Clone
Raw Blame History 15kB 398 lines
import { Component, Inject, OnInit, OnDestroy, NgZone, ChangeDetectorRef, ChangeDetectionStrategy, CUSTOM_ELEMENTS_SCHEMA, ViewEncapsulation, effect } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { CommonModule } from '@angular/common';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { FormsModule } from '@angular/forms';
import { BreakpointObserver } from '@angular/cdk/layout';

import { I18nService } from '../../services/i18n.service';
import { SocketService } from '../../services/socket.service';
import { CommonService } from '../../services/common.service';
import { MainCommandService } from '../../services/main-command.service';
import { ThemeService } from '../../services/theme.service';
import { TtlDialogService } from '../../dialogs/ttl-dialog.service';
import { KeyStringComponent } from './key/key-string.component';
import { KeyHashComponent } from './key/key-hash.component';
import { KeyListComponent } from './key/key-list.component';
import { KeySetComponent } from './key/key-set.component';
import { KeyZsetComponent } from './key/key-zset.component';
import { KeyStreamComponent } from './key/key-stream.component';
import { KeyJsonComponent } from './key/key-json.component';
import { KeyTimeseriesComponent } from './key/key-timeseries.component';
import { NavigationService } from '../../services/navigation.service';
import { RedisStateService } from '../../services/redis-state.service';
import { SettingsService } from '../../services/settings.service';

require('./database-key.component.scss');
require('./key/key-types.scss');


@Component({
    selector: 'p3xr-database-key',
    standalone: true,
    imports: [
        CommonModule,
        FormsModule,
        MatButtonModule,
        MatIconModule,
        MatTooltipModule,
        MatProgressSpinnerModule,
        MatButtonToggleModule,
        KeyStringComponent,
        KeyHashComponent,
        KeyListComponent,
        KeySetComponent,
        KeyZsetComponent,
        KeyStreamComponent,
        KeyJsonComponent,
        KeyTimeseriesComponent,
    ],
    schemas: [CUSTOM_ELEMENTS_SCHEMA],
    templateUrl: './database-key.component.html',
    encapsulation: ViewEncapsulation.None,
    changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DatabaseKeyComponent implements OnInit, OnDestroy {

    loading = false;
    response: any = null;
    key = '';
    isReadonly = false;
    isGtSm = true;
    valueFormat: 'raw' | 'json' | 'hex' | 'base64' = 'raw';
    strings;

    private ttlInterval: any;
    private wasExpiring = false;
    private readonly unsubFns: Array<() => void> = [];

    constructor(
        @Inject(NgZone) private readonly ngZone: NgZone,
        @Inject(BreakpointObserver) private readonly breakpointObserver: BreakpointObserver,
        @Inject(I18nService) private readonly i18n: I18nService,
        @Inject(SocketService) private readonly socket: SocketService,
        @Inject(CommonService) private readonly common: CommonService,
        @Inject(MainCommandService) private readonly cmd: MainCommandService,
        @Inject(ThemeService) private readonly theme: ThemeService,
        @Inject(TtlDialogService) private readonly ttlDialog: TtlDialogService,
        @Inject(NavigationService) private readonly nav: NavigationService,
        @Inject(ActivatedRoute) private readonly route: ActivatedRoute,
        @Inject(ChangeDetectorRef) private readonly cdr: ChangeDetectorRef,
        @Inject(RedisStateService) private readonly state: RedisStateService,
        @Inject(SettingsService) private readonly settings: SettingsService,
    ) {
        this.strings = this.i18n.strings;

        // Regenerate highlight when theme changes
        effect(() => {
            this.theme.currentTheme(); // track the signal
            if (this.key) {
                this.removeHighlight();
                this.generateHighlight();
            }
        });
    }

    ngOnInit(): void {
        this.key = this.getStateParam('key') || '';
        this.isReadonly = this.state.connection()?.readonly === true;

        const sub = this.breakpointObserver.observe('(min-width: 960px)').subscribe(r => {
            this.isGtSm = r.matches;
            this.cdr.markForCheck();
        });
        this.unsubFns.push(() => sub.unsubscribe());

        this.loadKey();
        this.generateHighlight();

        // Listen for refresh events via MainCommandService
        const refreshSub = this.cmd.refreshKey$.subscribe(() => {
            this.refresh({ withoutParent: true });
        });
        this.unsubFns.push(() => refreshSub.unsubscribe());

        // React to key-to-key navigation (Angular Router reuses the component)
        const paramSub = this.route.paramMap.subscribe(params => {
            const newKey = params.get('key') || '';
            if (newKey && newKey !== this.key) {
                this.key = newKey;
                this.loadKey();
                this.generateHighlight();
                this.cdr.markForCheck();
            }
        });
        this.unsubFns.push(() => paramSub.unsubscribe());
    }

    ngOnDestroy(): void {
        this.clearTtlInterval();
        this.removeHighlight();
        this.unsubFns.forEach(fn => fn());
    }

    // --- Actions ---

    addKey(event: Event): void {
        event.stopPropagation();
        this.cmd.keyNew$.next({ event, node: { key: this.key } });
    }

    deleteKey(event: Event): void {
        this.cmd.keyDelete$.next({ key: this.key, event });
    }

    rename(event: Event): void {
        this.cmd.keyRename$.next({ key: this.key, event });
    }

    async setTtl(event: Event): Promise<void> {
        try {
            const confirmResponse = await this.ttlDialog.show({
                $event: event,
                model: { ttl: this.response.ttl === -1 ? '' : this.response.ttl },
            });

            if (confirmResponse === undefined) return;

            const ttlStr = String(confirmResponse.model.ttl).trim();
            if (ttlStr === '' || confirmResponse.model.ttl == null) {
                await this.socket.request({ action: 'persist', payload: { key: this.key } });
                this.gtag('/persist');
                await this.refresh();
                this.common.toast(this.i18n.strings().status.persisted);
            } else if (!/^-?\d+$/.test(ttlStr)) {
                this.common.toast(this.i18n.strings().status.notInteger);
            } else {
                await this.socket.request({
                    action: 'expire',
                    payload: { key: this.key, ttl: parseInt(ttlStr) },
                });
                this.gtag('/expire');
                await this.refresh();
                this.common.toast(this.i18n.strings().status.ttlChanged);
            }
        } catch (e) {
            this.common.generalHandleError(e);
        }
    }

    async refresh(options: { withoutParent?: boolean } = {}): Promise<void> {
        this.gtag('/refresh');
        await this.loadKey(options);
    }

    charactersPrettyBytes(length: number): string {
        if (!length || length < 1024) return '';
        return '(' + (this.settings.prettyBytes(length) ?? '') + ')';
    }

    // --- Private ---

    private async loadKey(options: { withoutParent?: boolean } = {}): Promise<void> {
        this.clearTtlInterval();
        let hadError: any;
        try {
            const response = await this.socket.request({
                action: 'key-get',
                payload: { key: this.key },
            });
            this.response = response;

            if (response.ttl === -2) {
                this.checkTtl();
                return;
            }
            response.size = 0;
            this.decodeValueBuffer(response);
            this.calculateSize(response);

            if (response.ttl > -1) this.wasExpiring = true;
            this.loadTtl();
        } catch (e) {
            hadError = e;
            console.error(e);
            if ((e as any)?.message === 'Connection is closed.') {
                this.state.connection.set(undefined);
                this.common.alert((e as any)?.message ?? String(e));
            } else {
                this.common.alert(this.i18n.strings().label.unableToLoadKey({ key: this.key }));
            }
        } finally {
            if (hadError) {
                this.navigateTo('database.statistics');
            } else if (!options.withoutParent) {
                const resize = this.getStateParam('resize');
                if (resize) resize();
            }
            this.loading = false;
            this.cdr.markForCheck();
        }
    }

    private decodeValueBuffer(response: any): void {
        const { type, valueBuffer } = response;
        const td = new TextDecoder();
        switch (type) {
            case 'string':
                response.value = td.decode(valueBuffer);
                break;
            case 'list':
            case 'set':
                response.value = valueBuffer.map((buf: any) => td.decode(buf));
                break;
            case 'hash':
                response.value = {};
                Object.entries(valueBuffer).forEach(([key, buf]: [string, any]) => {
                    response.value[key] = td.decode(buf);
                });
                break;
            case 'zset':
                response.value = [];
                for (let i = 0; i < valueBuffer.length; i += 2) {
                    response.value.push(td.decode(valueBuffer[i]));
                    response.value.push(td.decode(valueBuffer[i + 1]));
                }
                break;
            case 'json':
                // JSON.GET with $ returns a JSON string (always compact from Redis)
                const rawJson = td.decode(valueBuffer);
                try {
                    const parsed = JSON.parse(rawJson);
                    // JSONPath $ returns array wrapper, unwrap it
                    const unwrapped = Array.isArray(parsed) ? parsed[0] : parsed;
                    response.value = JSON.stringify(unwrapped, null, this.settings.jsonFormat() ?? 2);
                } catch {
                    response.value = rawJson;
                }
                break;
            case 'stream':
                const decodeEntry = (entry: any): any => {
                    return entry.map((item: any) => {
                        if (Array.isArray(item)) return decodeEntry(item);
                        if (ArrayBuffer.isView(item) || item instanceof ArrayBuffer) return td.decode(item);
                        return item;
                    });
                };
                response.value = valueBuffer.map((entry: any) => decodeEntry(entry));
                break;
            case 'timeseries':
                // valueBuffer is a JSON-encoded TS.INFO object
                try {
                    response.value = JSON.parse(td.decode(valueBuffer));
                } catch {
                    response.value = {};
                }
                break;
        }
    }

    private calculateSize(response: any): void {
        if (response.type !== 'stream') {
            if (typeof response.valueBuffer === 'object' && response.length > 0) {
                for (const k of Object.keys(response.valueBuffer)) {
                    response.size += response.valueBuffer[k].byteLength;
                }
            } else if (Array.isArray(response.valueBuffer)) {
                for (const buf of response.valueBuffer) response.size += buf.byteLength;
            } else {
                response.size = response.valueBuffer.byteLength;
            }
        } else {
            const sumBytes = (arr: any[]): number => {
                let total = 0;
                const process = (el: any) => {
                    if (ArrayBuffer.isView(el) || el instanceof ArrayBuffer) total += el.byteLength;
                    else if (Array.isArray(el)) el.forEach(process);
                };
                arr.forEach(process);
                return total;
            };
            response.size = sumBytes(response.valueBuffer);
        }
    }

    private loadTtl(): void {
        if (!this.response || this.response.ttl <= -1) return;

        const humanizeDuration = require('humanize-duration');
        const updateTtl = () => {
            if (!this.checkTtl()) { this.clearTtlInterval(); return; }
            const hdOpts = this.settings.getHumanizeDurationOptions();
            const parsed = ' ' + humanizeDuration(this.response.ttl * 1000, {
                ...hdOpts,
                delimiter: ' ',
            });
            const el = document.getElementById('p3xr-database-key-ttl-counter');
            if (el) el.innerText = parsed;
        };
        updateTtl();

        if (!this.state.reducedFunctions()) {
            this.clearTtlInterval();
            this.ttlInterval = setInterval(() => {
                this.response.ttl--;
                updateTtl();
                this.cdr.markForCheck();
            }, 1000);
        }
    }

    private checkTtl(): boolean {
        if (this.response.ttl < -1 || (this.wasExpiring && this.response.ttl < 1)) {
            this.common.toast(this.i18n.strings().status.keyIsNotExisting);
            this.clearTtlInterval();
            this.state.redisChanged.set(true);
            this.navigateTo('database.statistics');
            return false;
        }
        return true;
    }

    private clearTtlInterval(): void {
        if (this.ttlInterval) { clearInterval(this.ttlInterval); this.ttlInterval = null; }
    }

    private generateHighlight(): void {
        this.removeHighlight();
        const currentTheme = this.theme.currentTheme() ?? '';
        const isDark = currentTheme.includes('Dark') || currentTheme.includes('Matrix');
        const bg = isDark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.15)';
        const color = isDark ? 'white' : 'black';
        const style = document.createElement('style');
        style.id = 'p3xr-theme-styles-tree-key';
        style.textContent = `[data-p3xr-tree-key="${(globalThis as any).htmlEncode?.(this.key) ?? ''}"] .p3xr-database-tree-node-label {
            background-color: ${bg} !important;
            color: ${color} !important;
            padding: 2px;
        }`;
        document.head.appendChild(style);
    }

    private removeHighlight(): void {
        document.getElementById('p3xr-theme-styles-tree-key')?.remove();
    }

    // --- Helpers ---

    private getStateParam(name: string): any {
        return this.route.snapshot.paramMap.get(name);
    }

    private navigateTo(state: string, params?: any): void {
        this.nav.navigateTo(state, params);
    }

    private gtag(page: string): void {
        try {
            if (typeof (window as any).gtag === 'function') {
                (window as any).gtag('config', this.settings.googleAnalytics, { page_path: page });
            }
        } catch { /* noop */ }
    }
}