RSS Git Download  Clone
Raw Blame History 51kB 1023 lines
import { Component, Inject, OnInit, OnDestroy, ChangeDetectorRef, ChangeDetectionStrategy } from '@angular/core';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatDividerModule } from '@angular/material/divider';
import { MatDialog } from '@angular/material/dialog';
import { BreakpointObserver } from '@angular/cdk/layout';
import { CdkDragDrop, DragDropModule, moveItemInArray } from '@angular/cdk/drag-drop';
import { I18nService } from '../services/i18n.service';
import { SettingsService } from '../services/settings.service';
import { CommonService } from '../services/common.service';
import { SocketService } from '../services/socket.service';
import { MainCommandService } from '../services/main-command.service';
import { ConnectionDialogService } from '../dialogs/connection-dialog.service';
import { createDialogPopupSettings } from '../dialogs/dialog-popup';
import { TreecontrolSettingsDialogService } from '../dialogs/treecontrol-settings-dialog.service';
import { P3xrAccordionComponent } from '../components/p3xr-accordion.component';
import { P3xrButtonComponent } from '../components/p3xr-button.component';
// import { DatePipe } from '../pipes/date.pipe';

declare const p3xr: any;

/**
 * Settings page — Angular replacement for AngularJS p3xrSettings.
 * First complete Angular page migration.
 *
 * Contains:
 * - Connections list (add/edit/delete/connect/disconnect)
 * - License info panel
 * - Tree settings panel
 */
@Component({
    selector: 'p3xr-ng-settings',
    standalone: true,
    imports: [
        // DatePipe,
        MatToolbarModule, MatButtonModule, MatIconModule, MatListModule,
        MatTooltipModule, MatDividerModule, DragDropModule,
        P3xrAccordionComponent, P3xrButtonComponent,
    ],
    template: `
        <!-- Connections -->
        <p3xr-ng-accordion [title]="strings().label?.connections || 'Connections'" accordionKey="settings">
            <div actions>
                <p3xr-ng-button
                    (click)="toggleGroupMode()"
                    [label]="strings().label?.grouped || 'Grouped'"
                    [mdIcon]="groupModeEnabled ? 'check_box' : 'check_box_outline_blank'">
                </p3xr-ng-button>
                @if (!readonlyConnections) {
                    <p3xr-ng-button
                        (click)="connectionForm('new')"
                        [label]="strings().intention?.connectionAdd || 'Add'"
                        mdIcon="add_box">
                    </p3xr-ng-button>
                }
            </div>
            <div content>
                @if (connectionsList.length === 0) {
                    <div style="padding: 16px;">
                        {{ strings().intention?.noConnectionsInSettings || 'No connections' }}
                    </div>
                }

                @if (connectionsList.length > 0) {
                    <div>
                        @if (groupModeEnabled) {
                        <div cdkDropList
                             [cdkDropListData]="groupedConnections"
                             (cdkDropListDropped)="dropGroup($event)"
                             [cdkDropListEnterPredicate]="groupDropPredicate"
                             class="p3xr-group-drop-list">
                        @for (group of groupedConnections; track group.name) {
                            <div cdkDrag class="p3xr-connection-group-block" [cdkDragData]="group">
                            <div class="p3xr-connection-group-header" cdkDragHandle (click)="toggleGroup(group.name)">
                                <mat-icon style="font-size: 18px; width: 18px; height: 18px;">{{ collapsedGroups.has(group.name) ? 'chevron_right' : 'expand_more' }}</mat-icon>
                                <span>{{ getGroupDisplayName(group.name) }}</span>
                                <span style="opacity: 0.5; font-weight: 400; font-size: 12px;">({{ group.connections.length }})</span>
                            </div>
                            @if (!collapsedGroups.has(group.name)) {
                                <div cdkDropList
                                     [cdkDropListData]="group.connections"
                                     (cdkDropListDropped)="dropConnection($event, group.name)"
                                     [cdkDropListEnterPredicate]="connectionDropPredicate">
                                @for (connection of group.connections; track connection.id; let last = $last) {
                                <div class="p3xr-connection-item" cdkDrag [cdkDragData]="connection">
                                    <div class="p3xr-connection-info">
                                        <div style="font-weight: 700;">{{ connection.name }}</div>
                                        <div style="font-size: 13px; opacity: 0.7;">{{ connection.host }}:{{ connection.port }}</div>
                                        <div style="font-size: 13px; opacity: 0.7;">
                                            @for (entry of getConnectionClients(connection); track entry.key) {
                                                {{ strings().page?.overview?.connectedCount?.({ length: entry.clients }) }}
                                            }
                                            &nbsp;
                                        </div>
                                    </div>

                                    @if (currentConnectionId !== connection.id) {
                                        @if (isXs) {
                                            <button mat-mini-fab class="btn-accent" (click)="connect(connection)"
                                                [matTooltip]="strings().intention?.connect || 'Connect'"
                                                [attr.aria-label]="strings().intention?.connect || 'Connect'">
                                                <mat-icon>power</mat-icon>
                                            </button>
                                        } @else {
                                            <button mat-flat-button class="btn-accent" (click)="connect(connection)">
                                                <mat-icon>power</mat-icon>
                                                <span>{{ strings().intention?.connect || 'Connect' }}</span>
                                            </button>
                                        }
                                    }

                                    @if (currentConnectionId === connection.id) {
                                        @if (isXs) {
                                            <button mat-mini-fab class="btn-accent" (click)="disconnect()"
                                                [matTooltip]="strings().intention?.disconnect || 'Disconnect'"
                                                matTooltipPosition="above"
                                                [attr.aria-label]="strings().intention?.disconnect || 'Disconnect'">
                                                <i class="fa fa-power-off"></i>
                                            </button>
                                        } @else {
                                            <button mat-flat-button class="btn-accent" (click)="disconnect()">
                                                <i class="fa fa-power-off"></i>
                                                <span>{{ strings().intention?.disconnect || 'Disconnect' }}</span>
                                            </button>
                                        }
                                    }

                                    @if (!readonlyConnections) {
                                        @if (isXs) {
                                            <button mat-mini-fab class="btn-warn" (click)="deleteConnection(connection, $event)"
                                                [matTooltip]="strings().intention?.delete || 'Delete'"
                                                [attr.aria-label]="strings().intention?.delete || 'Delete'">
                                                <mat-icon>delete_forever</mat-icon>
                                            </button>
                                        } @else {
                                            <button mat-flat-button class="btn-warn" (click)="deleteConnection(connection, $event)">
                                                <mat-icon>delete_forever</mat-icon>
                                                <span>{{ strings().intention?.delete || 'Delete' }}</span>
                                            </button>
                                        }
                                        @if (isXs) {
                                            <button mat-mini-fab class="btn-primary" (click)="connectionForm('edit', connection)"
                                                [matTooltip]="strings().intention?.edit || 'Edit'"
                                                [attr.aria-label]="strings().intention?.edit || 'Edit'">
                                                <mat-icon>edit</mat-icon>
                                            </button>
                                        } @else {
                                            <button mat-flat-button class="btn-primary" (click)="connectionForm('edit', connection)">
                                                <mat-icon>edit</mat-icon>
                                                <span>{{ strings().intention?.edit || 'Edit' }}</span>
                                            </button>
                                        }
                                    }

                                    @if (readonlyConnections) {
                                        @if (isXs) {
                                            <button mat-mini-fab class="btn-primary" (click)="connectionForm('edit', connection)"
                                                [matTooltip]="strings().intention?.view || 'View'"
                                                [attr.aria-label]="strings().intention?.view || 'View'">
                                                <mat-icon>mode_comment</mat-icon>
                                            </button>
                                        } @else {
                                            <button mat-flat-button class="btn-primary" (click)="connectionForm('edit', connection)">
                                                <mat-icon>mode_comment</mat-icon>
                                                <span>{{ strings().intention?.view || 'View' }}</span>
                                            </button>
                                        }
                                    }
                                </div>
                                @if (!last) { <mat-divider></mat-divider> }
                                }
                                </div>
                            }
                            </div>
                        }
                        </div>
                        }
                        @if (!groupModeEnabled) {
                            @for (connection of connectionsList; track connection.id; let last = $last) {
                                <div class="p3xr-connection-item">
                                    <div class="p3xr-connection-info">
                                        <div style="font-weight: 700;">{{ connection.name }}</div>
                                        <div style="font-size: 13px; opacity: 0.7;">{{ connection.host }}:{{ connection.port }}</div>
                                        <div style="font-size: 13px; opacity: 0.7;">
                                            @for (entry of getConnectionClients(connection); track entry.key) {
                                                {{ strings().page?.overview?.connectedCount?.({ length: entry.clients }) }}
                                            }
                                            &nbsp;
                                        </div>
                                    </div>

                                    @if (currentConnectionId !== connection.id) {
                                        @if (isXs) {
                                            <button mat-mini-fab class="btn-accent" (click)="connect(connection)"
                                                [matTooltip]="strings().intention?.connect || 'Connect'"
                                                [attr.aria-label]="strings().intention?.connect || 'Connect'">
                                                <mat-icon>power</mat-icon>
                                            </button>
                                        } @else {
                                            <button mat-flat-button class="btn-accent" (click)="connect(connection)">
                                                <mat-icon>power</mat-icon>
                                                <span>{{ strings().intention?.connect || 'Connect' }}</span>
                                            </button>
                                        }
                                    }

                                    @if (currentConnectionId === connection.id) {
                                        @if (isXs) {
                                            <button mat-mini-fab class="btn-accent" (click)="disconnect()"
                                                [matTooltip]="strings().intention?.disconnect || 'Disconnect'"
                                                matTooltipPosition="above"
                                                [attr.aria-label]="strings().intention?.disconnect || 'Disconnect'">
                                                <i class="fa fa-power-off"></i>
                                            </button>
                                        } @else {
                                            <button mat-flat-button class="btn-accent" (click)="disconnect()">
                                                <i class="fa fa-power-off"></i>
                                                <span>{{ strings().intention?.disconnect || 'Disconnect' }}</span>
                                            </button>
                                        }
                                    }

                                    @if (!readonlyConnections) {
                                        @if (isXs) {
                                            <button mat-mini-fab class="btn-warn" (click)="deleteConnection(connection, $event)"
                                                [matTooltip]="strings().intention?.delete || 'Delete'"
                                                [attr.aria-label]="strings().intention?.delete || 'Delete'">
                                                <mat-icon>delete_forever</mat-icon>
                                            </button>
                                        } @else {
                                            <button mat-flat-button class="btn-warn" (click)="deleteConnection(connection, $event)">
                                                <mat-icon>delete_forever</mat-icon>
                                                <span>{{ strings().intention?.delete || 'Delete' }}</span>
                                            </button>
                                        }
                                        @if (isXs) {
                                            <button mat-mini-fab class="btn-primary" (click)="connectionForm('edit', connection)"
                                                [matTooltip]="strings().intention?.edit || 'Edit'"
                                                [attr.aria-label]="strings().intention?.edit || 'Edit'">
                                                <mat-icon>edit</mat-icon>
                                            </button>
                                        } @else {
                                            <button mat-flat-button class="btn-primary" (click)="connectionForm('edit', connection)">
                                                <mat-icon>edit</mat-icon>
                                                <span>{{ strings().intention?.edit || 'Edit' }}</span>
                                            </button>
                                        }
                                    }

                                    @if (readonlyConnections) {
                                        @if (isXs) {
                                            <button mat-mini-fab class="btn-primary" (click)="connectionForm('edit', connection)"
                                                [matTooltip]="strings().intention?.view || 'View'"
                                                [attr.aria-label]="strings().intention?.view || 'View'">
                                                <mat-icon>mode_comment</mat-icon>
                                            </button>
                                        } @else {
                                            <button mat-flat-button class="btn-primary" (click)="connectionForm('edit', connection)">
                                                <mat-icon>mode_comment</mat-icon>
                                                <span>{{ strings().intention?.view || 'View' }}</span>
                                            </button>
                                        }
                                    }
                                </div>
                                @if (!last) { <mat-divider></mat-divider> }
                            }
                        }
                    </div>
                }
            </div>
        </p3xr-ng-accordion>

        <!-- License Info — hidden, all features are free
        <br />
        <p3xr-ng-accordion [title]="strings().label?.licenseInfo || 'License'" accordionKey="license-info">
            <div actions style="display: flex; align-items: center; gap: 4px;">
                <button mat-icon-button (click)="showLicenseTierPolicy($event)"
                    [matTooltip]="getLicenseTierPolicyTitle()">
                    <mat-icon>info</mat-icon>
                </button>
                <p3xr-ng-button
                    (click)="setLicense($event)"
                    [disabled]="!isLicenseEditable()"
                    [label]="strings().intention?.license || 'Set license'"
                    mdIcon="vpn_key">
                </p3xr-ng-button>
            </div>
            <div content>
                <mat-list>
                    <mat-list-item>
                        <div class="p3xr-settings-pair-row">
                            <div class="p3xr-settings-row-label">{{ strings().label?.licenseEditable }}</div>
                            <div class="p3xr-settings-row-value">{{ getLicenseEditableText() }}</div>
                        </div>
                    </mat-list-item>
                    <mat-divider></mat-divider>

                    @if (!isLicenseEditable()) {
                        <mat-list-item>
                            <div class="p3xr-settings-wrap-text">{{ strings().label?.licenseTerminalOnly }}</div>
                        </mat-list-item>
                        <mat-divider></mat-divider>
                    }

                    <mat-list-item>
                        <div class="p3xr-settings-pair-row">
                            <div class="p3xr-settings-row-label">{{ strings().label?.licenseState }}</div>
                            <div class="p3xr-settings-row-value">{{ getLicenseStateText() }}</div>
                        </div>
                    </mat-list-item>
                    <mat-divider></mat-divider>

                    <mat-list-item>
                        <div class="p3xr-settings-pair-row">
                            <div class="p3xr-settings-row-label">{{ strings().label?.licenseKeyMasked }}</div>
                            <div class="p3xr-settings-row-value">{{ getLicenseKeyText() }}</div>
                        </div>
                    </mat-list-item>
                    <mat-divider></mat-divider>

                    <mat-list-item>
                        <div class="p3xr-settings-pair-row">
                            <div class="p3xr-settings-row-label">{{ strings().label?.licenseTier }}</div>
                            <div class="p3xr-settings-row-value">{{ license.tier || 'free' }}</div>
                        </div>
                    </mat-list-item>
                    <mat-divider></mat-divider>

                    <mat-list-item>
                        <div class="p3xr-settings-pair-row p3xr-settings-pair-row-valid">
                            <div class="p3xr-settings-row-label">{{ strings().label?.licenseValid }}</div>
                            <div class="p3xr-settings-row-value">
                            @if (license.valid) {
                                <i class="fa fa-check" style="color: #4caf50;"></i>
                            } @else {
                                <i class="fa fa-times" style="color: #f44336;"></i>
                            }
                            </div>
                        </div>
                    </mat-list-item>
                    <mat-divider></mat-divider>

                    <mat-list-item>
                        <div class="p3xr-settings-pair-row">
                            <div class="p3xr-settings-row-label">{{ strings().label?.licenseStatus }}</div>
                            <div class="p3xr-settings-row-value">{{ getLicenseStatusText() }}</div>
                        </div>
                    </mat-list-item>
                    <mat-divider></mat-divider>

                    <mat-list-item>
                        <div class="p3xr-settings-pair-row">
                            <div class="p3xr-settings-row-label">{{ strings().label?.licenseReason }}</div>
                            <div class="p3xr-settings-row-value">{{ getLicenseReasonText() }}</div>
                        </div>
                    </mat-list-item>
                    <mat-divider></mat-divider>

                    <mat-list-item>
                        <div class="p3xr-settings-pair-row">
                            <div class="p3xr-settings-row-label">{{ strings().label?.licenseCheckedAt }}</div>
                            <div class="p3xr-settings-row-value">{{ license.checkedAt ? (license.checkedAt | date:'datetime') : '-' }}</div>
                        </div>
                    </mat-list-item>
                    <mat-divider></mat-divider>

                    <mat-list-item>
                        <div class="p3xr-settings-pair-row">
                            <div class="p3xr-settings-row-label">{{ strings().label?.licenseStartsAt }}</div>
                            <div class="p3xr-settings-row-value">{{ license.startsAt ? (license.startsAt | date:'datetime') : '-' }}</div>
                        </div>
                    </mat-list-item>
                    <mat-divider></mat-divider>

                    <mat-list-item>
                        <div class="p3xr-settings-pair-row">
                            <div class="p3xr-settings-row-label">{{ strings().label?.licenseExpiresAt }}</div>
                            <div class="p3xr-settings-row-value">{{ license.expiresAt ? (license.expiresAt | date:'datetime') : '-' }}</div>
                        </div>
                    </mat-list-item>
                    <mat-divider></mat-divider>

                    <mat-list-item>
                        <div class="p3xr-settings-pair-row">
                            <div class="p3xr-settings-row-label">{{ strings().label?.licenseDaysLeft }}</div>
                            <div class="p3xr-settings-row-value">{{ license.daysLeft != null ? license.daysLeft : '-' }}</div>
                        </div>
                    </mat-list-item>
                    <mat-divider></mat-divider>

                    <mat-list-item>
                        <div class="p3xr-settings-pair-row">
                            <div class="p3xr-settings-row-label">{{ strings().label?.licenseMaxDevices }}</div>
                            <div class="p3xr-settings-row-value">{{ getLicenseMaxDevicesText() }}</div>
                        </div>
                    </mat-list-item>
                    <mat-divider></mat-divider>

                    <mat-list-item class="p3xr-license-active-devices-item">
                        <div class="p3xr-license-active-devices-content">
                            <div class="p3xr-settings-pair-row">
                                <div class="p3xr-settings-row-label">{{ strings().label?.licenseActiveDevices }}</div>
                                <div class="p3xr-settings-row-value">{{ getLicenseActiveDevicesText() }}</div>
                            </div>
                            <div class="p3xr-license-active-devices-note">{{ strings().label?.licenseActiveDevicesInfo }}</div>
                        </div>
                    </mat-list-item>
                    <mat-divider></mat-divider>

                    <mat-list-item class="p3xr-license-features-item">
                        <div class="p3xr-license-features-row">
                            <div class="p3xr-settings-row-label">{{ strings().label?.licenseFeatures }}</div>
                            <div class="p3xr-license-features-text">{{ getLicenseFeaturesText() }}</div>
                        </div>
                    </mat-list-item>
                </mat-list>
            </div>
        </p3xr-ng-accordion>
        -->

        <br />

        <!-- Tree Settings -->
        <p3xr-ng-accordion [title]="strings().form?.treeSettings?.label?.formName || 'Redis Settings'" accordionKey="tree-settings">
            <div actions>
                <p3xr-ng-button
                    (click)="openTreeSettings($event)"
                    [label]="strings().intention?.edit || 'Edit'"
                    mdIcon="edit">
                </p3xr-ng-button>
            </div>
            <div content>
                <mat-list class="p3xr-tree-settings-list">
                    <mat-list-item (click)="openTreeSettings($event)">
                        <div style="display: flex; width: 100%;"><span class="p3xr-settings-label" style="flex: 1;">{{ strings().form?.treeSettings?.field?.treeSeparator }}</span>
                        <span>{{ settings.redisTreeDivider() || strings().label?.treeSeparatorEmptyNote }}</span></div>
                    </mat-list-item>
                    <mat-divider></mat-divider>

                    <mat-list-item (click)="openTreeSettings($event)">
                        <div><div style="display: flex; width: 100%;"><span class="p3xr-settings-label" style="flex: 1;">{{ strings().form?.treeSettings?.field?.page }}</span><span class="p3xr-settings-value">{{ settings.pageCount() }}</span></div>
                        <div class="p3xr-settings-hint">{{ strings().form?.treeSettings?.error?.page }}</div></div>
                    </mat-list-item>
                    <mat-divider></mat-divider>

                    <mat-list-item (click)="openTreeSettings($event)">
                        <div><div style="display: flex; width: 100%;"><span class="p3xr-settings-label" style="flex: 1;">{{ strings().form?.treeSettings?.field?.keyPageCount }}</span><span class="p3xr-settings-value">{{ settings.keyPageCount() }}</span></div>
                        <div class="p3xr-settings-hint">{{ strings().form?.treeSettings?.error?.keyPageCount }}</div></div>
                    </mat-list-item>
                    <mat-divider></mat-divider>

                    <mat-list-item (click)="openTreeSettings($event)">
                        <div><div style="display: flex; width: 100%;"><span class="p3xr-settings-label" style="flex: 1;">{{ strings().form?.treeSettings?.maxValueDisplay }}</span><span class="p3xr-settings-value">{{ settings.maxValueDisplay() }}</span></div>
                        <div class="p3xr-settings-hint">{{ strings().form?.treeSettings?.maxValueDisplayInfo }}</div></div>
                    </mat-list-item>
                    <mat-divider></mat-divider>

                    <mat-list-item (click)="openTreeSettings($event)">
                        <div><div style="display: flex; width: 100%;"><span class="p3xr-settings-label" style="flex: 1;">{{ strings().form?.treeSettings?.maxKeys }}</span><span class="p3xr-settings-value">{{ settings.maxKeys() }}</span></div>
                        <div class="p3xr-settings-hint">{{ strings().form?.treeSettings?.maxKeysInfo }}</div></div>
                    </mat-list-item>
                    <mat-divider></mat-divider>

                    <mat-list-item (click)="openTreeSettings($event)">
                        <div style="display: flex; width: 100%;"><span class="p3xr-settings-label" style="flex: 1;">{{ strings().form?.treeSettings?.field?.keysSort }}</span>
                        <span>{{ settings.keysSort() ? strings().label?.keysSort?.on : strings().label?.keysSort?.off }}</span></div>
                    </mat-list-item>
                    <mat-divider></mat-divider>

                    <mat-list-item (click)="openTreeSettings($event)">
                        <div style="display: flex; width: 100%;"><span class="p3xr-settings-label" style="flex: 1;">{{ strings().form?.treeSettings?.field?.searchMode }}</span>
                        <span>{{ settings.searchClientSide() ? strings().form?.treeSettings?.label?.searchModeClient : strings().form?.treeSettings?.label?.searchModeServer }}</span></div>
                    </mat-list-item>
                    <mat-divider></mat-divider>

                    <mat-list-item (click)="openTreeSettings($event)">
                        <div style="display: flex; width: 100%;"><span class="p3xr-settings-label" style="flex: 1;">{{ strings().form?.treeSettings?.field?.searchModeStartsWith }}</span>
                        <span>{{ settings.searchStartsWith() ? strings().form?.treeSettings?.label?.searchModeStartsWith : strings().form?.treeSettings?.label?.searchModeIncludes }}</span></div>
                    </mat-list-item>
                    <mat-divider></mat-divider>

                    <mat-list-item (click)="openTreeSettings($event)">
                        <div style="display: flex; width: 100%;">{{ settings.jsonFormat() === 2 ? strings().form?.treeSettings?.label?.jsonFormatTwoSpace : strings().form?.treeSettings?.label?.jsonFormatFourSpace }}</div>
                    </mat-list-item>
                    <mat-divider></mat-divider>

                    <mat-list-item (click)="openTreeSettings($event)">
                        <div style="display: flex; width: 100%;">{{ settings.animation() ? strings().form?.treeSettings?.label?.animation : strings().form?.treeSettings?.label?.noAnimation }}</div>
                    </mat-list-item>
                </mat-list>
            </div>
        </p3xr-ng-accordion>

        <!-- Bottom spacing to prevent overlap with fixed footer bar -->
        <div style="height: 60px;"></div>
    `,
    styles: [`
        :host { display: block; color: var(--mat-app-text-color, inherit); }
        .p3xr-settings-hint {
            font-size: 12px;
            color: var(--mat-app-text-color, rgba(0, 0, 0, 0.54));
            opacity: 0.7;
        }
        /* Wide screens: show button text, hide tooltip */
        .hide-xs { display: inline; }
        .show-xs-tooltip { display: none; }
        /* Small screens: hide text, show icon-only square buttons */
        @media (max-width: 599px) {
            .hide-xs { display: none !important; }
            /* Buttons become square icon buttons on mobile */
            .p3xr-connection-item button {
                min-width: 40px !important;
                width: 40px !important;
                height: 40px !important;
                padding: 0 !important;
                margin: 2px !important;
                display: inline-flex !important;
                align-items: center !important;
                justify-content: center !important;
            }
            .p3xr-connection-item button mat-icon,
            .p3xr-connection-item button i {
                margin: 0 !important;
            }
        }
        /* Connection items: match production md-list-item */
        .p3xr-connection-item {
            display: flex;
            align-items: center;
            gap: 4px;
            padding: 8px 8px 8px 16px;
            min-height: 56px;
            box-sizing: border-box;
        }
        .p3xr-connection-info { flex: 1; min-width: 0; overflow: hidden; }
        .p3xr-connection-item button { flex-shrink: 0; }
        /* Drag and drop */
        .p3xr-connection-item.cdk-drag-preview {
            box-sizing: border-box;
            border-radius: 4px;
            box-shadow: 0 5px 5px -3px rgba(0,0,0,0.2), 0 8px 10px 1px rgba(0,0,0,0.14), 0 3px 14px 2px rgba(0,0,0,0.12);
            background: var(--mat-app-background-color, #fff);
        }
        .p3xr-connection-item.cdk-drag-placeholder { opacity: 0.3; }
        .cdk-drop-list-dragging .p3xr-connection-item:not(.cdk-drag-placeholder) {
            transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
        }
        .p3xr-connection-group-block.cdk-drag-preview {
            box-sizing: border-box;
            border-radius: 4px;
            box-shadow: 0 5px 5px -3px rgba(0,0,0,0.2), 0 8px 10px 1px rgba(0,0,0,0.14), 0 3px 14px 2px rgba(0,0,0,0.12);
            background: var(--mat-app-background-color, #fff);
        }
        .p3xr-connection-group-block.cdk-drag-placeholder { opacity: 0.3; }
        .p3xr-group-drop-list.cdk-drop-list-dragging .p3xr-connection-group-block:not(.cdk-drag-placeholder) {
            transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
        }
        .p3xr-connection-group-header[cdkDragHandle] { cursor: grab; }
        /* Only tree settings rows are clickable/hoverable. License rows stay static like AngularJS. */
        .p3xr-tree-settings-list mat-list-item { cursor: pointer; }
        .p3xr-tree-settings-list mat-list-item:hover { background-color: var(--p3xr-hover-bg); }
        /* Settings list: bold label (left), normal value (right) */
        ::ng-deep .p3xr-tree-settings-list .mdc-list-item__primary-text {
            width: 100%;
        }
        ::ng-deep .p3xr-settings-label {
            font-weight: 500;
        }
        ::ng-deep .p3xr-settings-value {
            font-weight: 400;
            opacity: 0.8;
        }
    `],
    changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SettingsComponent implements OnInit, OnDestroy {
    private static readonly UNGROUPED_GROUP_KEY = '';
    strings;
    connectionsList: any[] = [];
    groupedConnections: Array<{ name: string; connections: any[] }> = [];
    collapsedGroups: Set<string>;
    groupModeEnabled = false;

    private static readonly COLLAPSED_GROUPS_KEY = 'p3xr-collapsed-connection-groups';
    private static readonly GROUP_MODE_KEY = 'p3xr-connection-group-mode';
    readonlyConnections = false;
    currentConnectionId: string | undefined;
    license: any = {};
    isXs = false;
    private electronUiStorage: Record<string, string> | null = null;

    private readonly unsubs: Array<() => void> = [];

    constructor(
        @Inject(I18nService) private i18n: I18nService,
        @Inject(SettingsService) public settings: SettingsService,
        @Inject(CommonService) private common: CommonService,
        @Inject(SocketService) private socket: SocketService,
        @Inject(MainCommandService) private cmd: MainCommandService,
        @Inject(ConnectionDialogService) private connectionDialog: ConnectionDialogService,
        @Inject(TreecontrolSettingsDialogService) private treeSettingsDialog: TreecontrolSettingsDialogService,
        @Inject(MatDialog) private dialog: MatDialog,
        @Inject(BreakpointObserver) private breakpointObserver: BreakpointObserver,
        @Inject(ChangeDetectorRef) private cdr: ChangeDetectorRef,
    ) {
        this.strings = this.i18n.strings;

        this.restoreGroupingState();

        this.breakpointObserver.observe('(max-width: 599px)').subscribe(result => {
            this.isXs = result.matches;
            this.cdr.markForCheck();
        });
    }

    ngOnInit(): void {
        this.refreshState();

        // Subscribe to socket events for reactive updates
        const sub1 = this.socket.connections$.subscribe(() => this.refreshState());
        const sub2 = this.socket.configuration$.subscribe(() => this.refreshState());
        const sub3 = this.socket.licenseUpdate$.subscribe(() => this.refreshState());
        const sub4 = this.socket.stateChanged$.subscribe(() => this.refreshState());
        const sub5 = this.socket.redisStatus$.subscribe(() => this.refreshState());
        this.unsubs.push(() => { sub1.unsubscribe(); sub2.unsubscribe(); sub3.unsubscribe(); sub4.unsubscribe(); sub5.unsubscribe(); });
    }

    ngOnDestroy(): void {
        this.unsubs.forEach(fn => fn());
    }

    private refreshState(): void {
        const state = p3xr.state;
        this.connectionsList = state.connections?.list || [];
        this.readonlyConnections = state.cfg?.readonlyConnections === true;
        this.currentConnectionId = state.connection?.id;
        this.license = state.license || {};
        this.buildGroupedConnections();
        this.cdr.detectChanges();
    }

    toggleGroupMode(): void {
        this.groupModeEnabled = !this.groupModeEnabled;
        this.setPersistentItem(SettingsComponent.GROUP_MODE_KEY, String(this.groupModeEnabled));
    }

    toggleGroup(name: string): void {
        if (this.collapsedGroups.has(name)) {
            this.collapsedGroups.delete(name);
        } else {
            this.collapsedGroups.add(name);
        }
        this.setPersistentItem(SettingsComponent.COLLAPSED_GROUPS_KEY, JSON.stringify([...this.collapsedGroups]));
    }

    private restoreGroupingState(): void {
        this.groupModeEnabled = this.getPersistentItem(SettingsComponent.GROUP_MODE_KEY) === 'true';
        try {
            const stored = this.getPersistentItem(SettingsComponent.COLLAPSED_GROUPS_KEY);
            this.collapsedGroups = stored
                ? new Set(JSON.parse(stored).map((name: string) => this.normalizeCollapsedGroupName(name)))
                : new Set();
        } catch {
            this.collapsedGroups = new Set();
        }
    }

    private getPersistentItem(key: string): string | null {
        const value = this.getElectronUiStorage()[key];
        if (typeof value === 'string') {
            return value;
        }

        try {
            return localStorage.getItem(key);
        } catch {
            return null;
        }
    }

    private setPersistentItem(key: string, value: string): void {
        try {
            localStorage.setItem(key, value);
        } catch { /* ignore */ }

        const storage = this.getElectronUiStorage();
        storage[key] = value;
        this.electronUiStorage = storage;

        try {
            if (window.parent && window.parent !== window) {
                window.parent.postMessage({ type: 'p3x-ui-storage-set', key, value }, '*');
            }
        } catch { /* ignore */ }
    }

    private getElectronUiStorage(): Record<string, string> {
        if (this.electronUiStorage !== null) {
            return this.electronUiStorage;
        }

        // Read from p3xr.electronUiStorageBootstrap which was captured in main.js
        // BEFORE Angular's router stripped the query params.
        let storage: Record<string, string> = {};
        try {
            const bootstrap = p3xr?.electronUiStorageBootstrap;
            if (bootstrap && typeof bootstrap === 'object' && !Array.isArray(bootstrap)) {
                storage = this.normalizeElectronUiStorage(bootstrap);
            }
        } catch {
            storage = {};
        }

        this.electronUiStorage = storage;
        return storage;
    }

    private normalizeElectronUiStorage(value: unknown): Record<string, string> {
        if (!value || typeof value !== 'object' || Array.isArray(value)) {
            return {};
        }

        return Object.entries(value).reduce((result: Record<string, string>, [key, entryValue]) => {
            if (typeof entryValue === 'string') {
                result[key] = entryValue;
            }
            return result;
        }, {});
    }

    getGroupDisplayName(name: string): string {
        return name === SettingsComponent.UNGROUPED_GROUP_KEY
            ? this.getUngroupedLabel()
            : name;
    }

    private getUngroupedLabel(): string {
        return this.strings().label?.ungrouped || 'Ungrouped';
    }

    private normalizeCollapsedGroupName(name: unknown): string {
        if (typeof name !== 'string') {
            return '';
        }

        return this.isLegacyUngroupedGroupName(name)
            ? SettingsComponent.UNGROUPED_GROUP_KEY
            : name;
    }

    private isLegacyUngroupedGroupName(name: string): boolean {
        return name === 'Ungrouped' || name === this.getUngroupedLabel();
    }

    private buildGroupedConnections(): void {
        // Use a Map to preserve the order groups first appear in the connections list.
        // This respects the server-persisted order (including after drag reorder).
        const groups = new Map<string, any[]>();
        for (const conn of this.connectionsList) {
            const groupName = conn.group?.trim() || SettingsComponent.UNGROUPED_GROUP_KEY;
            if (!groups.has(groupName)) {
                groups.set(groupName, []);
            }
            groups.get(groupName)!.push(conn);
        }
        const result: Array<{ name: string; connections: any[] }> = [];
        for (const [name, connections] of groups) {
            result.push({ name, connections });
        }
        this.groupedConnections = result;
    }

    // Predicates prevent items from entering the wrong drop list level
    groupDropPredicate = (drag: any) => drag.data && 'connections' in drag.data;
    connectionDropPredicate = (drag: any) => drag.data && !('connections' in drag.data);

    async dropGroup(event: CdkDragDrop<any[]>): Promise<void> {
        if (event.previousIndex === event.currentIndex) return;
        moveItemInArray(this.groupedConnections, event.previousIndex, event.currentIndex);
        // Rebuild flat list in new group order and persist
        const allIds: string[] = [];
        for (const group of this.groupedConnections) {
            for (const conn of group.connections) {
                allIds.push(conn.id);
            }
        }
        try {
            await this.socket.request({
                action: 'connections-reorder',
                payload: { ids: allIds },
            });
        } catch (e) {
            this.common.generalHandleError(e);
            this.refreshState();
        }
    }

    async dropConnection(event: CdkDragDrop<any[]>, groupName: string): Promise<void> {
        if (event.previousIndex === event.currentIndex) return;
        moveItemInArray(event.container.data, event.previousIndex, event.currentIndex);
        // Persist the new order to the server
        try {
            const reorderedIds = event.container.data.map((c: any) => c.id);
            await this.socket.request({
                action: 'connections-reorder',
                payload: { group: groupName || undefined, ids: reorderedIds },
            });
        } catch (e) {
            this.common.generalHandleError(e);
            this.refreshState();
        }
    }

    // --- Connections ---

    connectionForm(type: string, model?: any): void {
        this.connectionDialog.show({ type: type as any, model, $event: undefined });
    }

    async connect(connection: any): Promise<void> {
        this.cmd.connectRequest$.next({ connection, disableState: true });
    }

    async disconnect(): Promise<void> {
        await this.cmd.disconnect();
        this.refreshState();
    }

    async deleteConnection(connection: any, $event: any): Promise<void> {
        try {
            await this.common.confirm({
                event: $event,
                message: this.strings().confirm?.deleteConnectionText || 'Delete this connection?',
            });
            await this.socket.request({
                action: 'connection-delete',
                payload: { id: connection.id },
            });
            this.common.toast(this.strings().status?.deleted || 'Deleted');
        } catch (e) {
            if (e !== undefined) {
                this.common.generalHandleError(e);
            }
        }
    }

    getConnectionClients(connection: any): { key: string; clients: number }[] {
        const redisConnections = p3xr.state.redisConnections || {};
        const results: { key: string; clients: number }[] = [];
        for (const key of Object.keys(redisConnections)) {
            if (redisConnections[key].connection?.name === connection.name) {
                results.push({ key, clients: redisConnections[key].clients?.length || 0 });
            }
        }
        return results;
    }

    // --- License ---

    getLicense(): any {
        return this.license;
    }

    isLicenseEditable(): boolean {
        const l = this.license;
        if (typeof l.licenseEditable === 'boolean') return l.licenseEditable;
        if (typeof l.editableActive === 'boolean') return l.editableActive;
        if (typeof l.disabled === 'boolean') return !l.disabled;
        return true;
    }

    getEffectiveLicenseTier(): string {
        // All features are free — always return enterprise tier
        return 'enterprise';
    }

    getLicenseEditableText(): string {
        return this.isLicenseEditable()
            ? (this.strings().label?.licenseEditableYes || 'Yes')
            : (this.strings().label?.licenseEditableNo || 'No');
    }

    getLicenseStateText(): string {
        const l = this.license;
        if (l.hasLicenseKey !== true) return this.strings().label?.licenseStateNoLicense || 'No license';
        if (l.valid === true && l.licenseStatus === 'active') return this.strings().label?.licenseStateActive || 'Active';
        return this.strings().label?.licenseStateInactive || 'Inactive';
    }

    getLicenseKeyText(): string {
        const l = this.license;
        if (l.hasLicenseKey !== true) return '-';
        return typeof l.licenseKeyMasked === 'string' && l.licenseKeyMasked.length > 0 ? l.licenseKeyMasked : '****';
    }

    getLicenseStatusText(): string {
        const status = typeof this.license.licenseStatus === 'string' ? this.license.licenseStatus : '';
        if (!status) return '-';
        const map = this.strings().licenseStatusValue;
        return map?.[status] || status;
    }

    getLicenseReasonText(): string {
        const reason = typeof this.license.reason === 'string' ? this.license.reason : '';
        if (!reason) return '-';
        const map = this.strings().licenseReason;
        return map?.[reason] || reason;
    }

    getLicenseMaxDevicesText(): string {
        const l = this.license;
        const max = typeof l.maxDevices === 'number' ? l.maxDevices : l.deviceLease?.maxDevices;
        if (typeof max !== 'number' || !Number.isFinite(max) || max <= 0) return '-';
        return `${Math.floor(max)}`;
    }

    getLicenseActiveDevicesText(): string {
        const l = this.license;
        const active = typeof l.activeDevices === 'number' ? l.activeDevices : l.deviceLease?.activeDevices;
        if (typeof active !== 'number' || !Number.isFinite(active) || active < 0) return '-';
        const max = typeof l.maxDevices === 'number' ? l.maxDevices : l.deviceLease?.maxDevices;
        if (typeof max === 'number' && Number.isFinite(max) && max > 0) return `${Math.floor(active)} / ${Math.floor(max)}`;
        return `${Math.floor(active)}`;
    }

    getLicenseFeaturesText(): string {
        const s = this.strings();
        const l = this.license;
        const features: string[] = [];
        if (Array.isArray(l.features)) {
            for (const f of l.features) {
                if (typeof f === 'string' && f.length > 0) features.push(f);
            }
        }
        const tier = this.getEffectiveLicenseTier();
        const derived: string[] = [];
        if (tier === 'pro' || tier === 'enterprise') {
            derived.push(s.label?.licenseFeatureSsh || 'SSH');
            if (tier === 'enterprise') {
                derived.push(s.label?.licenseFeatureCluster || 'Cluster');
                derived.push(s.label?.licenseFeatureSentinel || 'Sentinel');
            }
            derived.push(s.label?.licenseFeatureReadonlyMode || 'Readonly');
            derived.push(s.intention?.jsonViewEditor || 'JSON Editor');
            derived.push(s.intention?.setBuffer || 'Buffer Upload');
            derived.push(s.intention?.downloadBuffer || 'Buffer Download');
            derived.push(s.label?.licenseFeatureReJSON || 'ReJSON');
        }
        for (const d of derived) {
            if (d && !features.includes(d)) features.push(d);
        }
        return features.length === 0 ? (s.label?.licenseFeaturesEmpty || '-') : features.join(', ');
    }

    getLicenseTierPolicyTitle(): string {
        return `${this.strings().label?.licenseTierPolicyTitle || 'License Policy'}: ${this.getEffectiveLicenseTier()}`;
    }

    async setLicense($event: any): Promise<void> {
        if (!this.isLicenseEditable()) {
            this.common.toast(this.strings().error?.license_readonly || 'License is read-only');
            return;
        }
        try {
            const { PromptDialogComponent } = await import(
                /* webpackChunkName: "dialog-prompt" */
                '../dialogs/prompt-dialog.component'
            );
            const s = this.strings();
            const dialogRef = this.dialog.open(PromptDialogComponent, createDialogPopupSettings({
                panelClass: 'p3xr-license-dialog',
                data: {
                    title: s.confirm?.license?.title || 'License',
                    placeholder: s.confirm?.license?.placeholder || 'Enter license key',
                    initialValue: '',
                    okButton: s.intention?.license || 'Set License',
                    cancelButton: s.intention?.cancel || 'Cancel',
                },
            }));
            const licenseKey = await new Promise<string | undefined>((resolve) => {
                dialogRef.afterClosed().subscribe(resolve);
            });
            if (licenseKey === undefined) return;

            const response = await this.socket.request({
                action: 'set-license',
                payload: { license: licenseKey },
            });
            this.common.toast(
                response.info !== 'ok'
                    ? (this.strings().error?.[response.info] || response.info)
                    : (this.strings().status?.licenseSaved || 'License saved')
            );
        } catch (e) {
            if (e !== undefined) {
                const msg = (e as any)?.message;
                if (this.strings().error?.[msg]) {
                    e = new Error(this.strings().error[msg]);
                }
                this.common.generalHandleError(e);
            }
        }
    }

    async showLicenseTierPolicy($event: any): Promise<void> {
        await this.common.alert({
            title: this.getLicenseTierPolicyTitle(),
            message: this.strings().label?.licenseTierPolicyText || '',
            panelClass: 'p3xr-license-tier-policy-dialog',
            autoFocus: false,
        });
    }

    // --- Tree Settings ---

    openTreeSettings($event: any): void {
        this.treeSettingsDialog.show({ $event });
    }
}