feat(ui): simplify KnockPage to minimal inline-only UI for article
This commit is contained in:
1884
article/struct-exaple.md
Normal file
1884
article/struct-exaple.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,65 +1,8 @@
|
||||
<div class="container">
|
||||
<p-card [header]="cardHeader">
|
||||
<ng-template pTemplate="header">
|
||||
<div class="flex justify-content-between align-items-center">
|
||||
<h1 style="margin-left: 1rem">Port Knocker</h1>
|
||||
<!-- <div class="animated-title" [class.animating]="isAnimating">
|
||||
<span *ngIf="cardHeader">{{ cardHeader }}</span>
|
||||
</div> -->
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
*ngIf="!enableFSA"
|
||||
pButton
|
||||
type="button"
|
||||
label="📁 Info"
|
||||
class="p-button-text p-button-sm"
|
||||
(click)="showInfoDialog = true"
|
||||
></button>
|
||||
<button
|
||||
*ngIf="canUseFSA && !enableFSA"
|
||||
pButton
|
||||
type="button"
|
||||
label="🚀 FSA Version"
|
||||
class="p-button-text p-button-sm"
|
||||
routerLink="/fsa"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
<p-card header="Port Knocker (Минимальный UI)">
|
||||
<form [formGroup]="form" (ngSubmit)="execute()" class="p-fluid">
|
||||
<div class="grid">
|
||||
<div class="col-12 md:col-6">
|
||||
<label>Password</label>
|
||||
<p-password
|
||||
formControlName="password"
|
||||
[feedback]="false"
|
||||
toggleMask
|
||||
inputStyleClass="w-full"
|
||||
placeholder="GO_KNOCKER_SERVE_PASS"
|
||||
></p-password>
|
||||
<div class="mt-1 text-sm" *ngIf="!form.value.password || wrongPass">
|
||||
<span class="text-red-500" *ngIf="wrongPass">Invalid password</span>
|
||||
<span class="text-600" *ngIf="!wrongPass && !form.value.password"
|
||||
>Password is required</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-6">
|
||||
<label>Mode</label>
|
||||
<p-dropdown
|
||||
formControlName="mode"
|
||||
[options]="[
|
||||
{ label: 'Inline', value: 'inline' },
|
||||
{ label: 'YAML', value: 'yaml' }
|
||||
]"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
class="w-full"
|
||||
></p-dropdown>
|
||||
</div>
|
||||
|
||||
<div class="col-12" *ngIf="form.value.mode === 'inline'">
|
||||
<div class="col-12">
|
||||
<label>Targets</label>
|
||||
<input
|
||||
pInputText
|
||||
@@ -70,7 +13,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-4" *ngIf="form.value.mode === 'inline'">
|
||||
<div class="col-12 md:col-6">
|
||||
<label>Delay</label>
|
||||
<input
|
||||
pInputText
|
||||
@@ -81,83 +24,11 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-6 md:col-4 flex align-items-center gap-2">
|
||||
<p-checkbox formControlName="verbose" [binary]="true"></p-checkbox>
|
||||
<label class="checkbox-label">Verbose</label>
|
||||
</div>
|
||||
|
||||
<div class="col-6 md:col-4 flex align-items-center gap-2">
|
||||
<p-checkbox
|
||||
formControlName="waitConnection"
|
||||
[binary]="true"
|
||||
></p-checkbox>
|
||||
<div class="col-12 md:col-6 flex align-items-center gap-2">
|
||||
<p-checkbox formControlName="waitConnection" [binary]="true"></p-checkbox>
|
||||
<label class="checkbox-label">Wait connection</label>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label>Gateway</label>
|
||||
<input
|
||||
pInputText
|
||||
type="text"
|
||||
formControlName="gateway"
|
||||
placeholder="optional local ip:port"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-12" *ngIf="form.value.mode === 'yaml'">
|
||||
<label>YAML</label>
|
||||
<textarea
|
||||
pInputTextarea
|
||||
formControlName="configYAML"
|
||||
rows="12"
|
||||
placeholder="paste YAML or ENCRYPTED:"
|
||||
class="w-full"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- File controls directly under YAML -->
|
||||
<div class="col-12" *ngIf="form.value.mode === 'yaml'">
|
||||
<div class="flex flex-wrap gap-2 align-items-center">
|
||||
<!-- FSA version -->
|
||||
<button
|
||||
*ngIf="enableFSA"
|
||||
pButton
|
||||
type="button"
|
||||
label="Open File (with write access)"
|
||||
(click)="openFileWithWriteAccess()"
|
||||
class="p-button-outlined"
|
||||
></button>
|
||||
<span
|
||||
*ngIf="enableFSA && selectedFileName"
|
||||
class="text-sm text-600"
|
||||
>{{ selectedFileName }}</span
|
||||
>
|
||||
|
||||
<!-- Basic version -->
|
||||
<p-fileUpload
|
||||
*ngIf="!enableFSA"
|
||||
mode="basic"
|
||||
name="file"
|
||||
chooseLabel="Choose File"
|
||||
(onSelect)="onFileUpload($event)"
|
||||
[customUpload]="true"
|
||||
[auto]="false"
|
||||
accept=".yaml,.yml,.txt,.encrypted"
|
||||
[maxFileSize]="1048576"
|
||||
></p-fileUpload>
|
||||
<input
|
||||
*ngIf="!enableFSA && !isYamlEncrypted()"
|
||||
pInputText
|
||||
type="text"
|
||||
class="w-full md:w-6"
|
||||
placeholder="Server file path (optional)"
|
||||
formControlName="serverFilePath"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 1: Execute full width -->
|
||||
<div class="col-12">
|
||||
<button
|
||||
pButton
|
||||
@@ -165,58 +36,7 @@
|
||||
label="Execute"
|
||||
class="w-full"
|
||||
[loading]="executing"
|
||||
[disabled]="executing || !form.value.password || wrongPass"
|
||||
[ngClass]="{ 'p-button-danger': !form.value.password || wrongPass }"
|
||||
></button>
|
||||
</div>
|
||||
<!-- Row 2: Encrypt / Decrypt half/half on desktop; stacked on mobile (только для YAML режима) -->
|
||||
<div class="col-12 md:col-6" *ngIf="!isInlineMode()">
|
||||
<button
|
||||
pButton
|
||||
type="button"
|
||||
label="Encrypt"
|
||||
(click)="encrypt()"
|
||||
class="p-button-secondary w-full"
|
||||
[disabled]="
|
||||
executing ||
|
||||
!form.value.password ||
|
||||
wrongPass ||
|
||||
isYamlEncrypted()
|
||||
"
|
||||
></button>
|
||||
</div>
|
||||
<div class="col-12 md:col-6" *ngIf="!isInlineMode()">
|
||||
<button
|
||||
pButton
|
||||
type="button"
|
||||
label="Decrypt"
|
||||
(click)="decrypt()"
|
||||
class="p-button-secondary w-full"
|
||||
[disabled]="
|
||||
executing ||
|
||||
!form.value.password ||
|
||||
wrongPass ||
|
||||
!isYamlEncrypted()
|
||||
"
|
||||
></button>
|
||||
</div>
|
||||
<!-- Row 3: Download actions half/half on desktop; stacked on mobile (только для YAML режима) -->
|
||||
<div class="col-12 md:col-6" *ngIf="!isInlineMode()">
|
||||
<button
|
||||
pButton
|
||||
type="button"
|
||||
label="Download YAML"
|
||||
(click)="downloadYaml()"
|
||||
class="p-button-text w-full"
|
||||
></button>
|
||||
</div>
|
||||
<div class="col-12 md:col-6" *ngIf="!isInlineMode()">
|
||||
<button
|
||||
pButton
|
||||
type="button"
|
||||
label="Download Result"
|
||||
(click)="downloadResult()"
|
||||
class="p-button-text w-full"
|
||||
[disabled]="executing || form.invalid"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -231,87 +51,15 @@
|
||||
[mode]="executing ? 'indeterminate' : 'determinate'"
|
||||
></p-progressBar>
|
||||
<div class="mt-2 text-600" *ngIf="executing">
|
||||
Elapsed: {{ elapsedMs / 1000 | number : "1.1-1" }}s
|
||||
Elapsed: {{ elapsedMs / 1000 | number : '1.1-1' }}s
|
||||
</div>
|
||||
<div class="mt-2 text-600" *ngIf="!executing && elapsedMs > 0">
|
||||
Last run: {{ elapsedMs / 1000 | number : "1.1-1" }}s
|
||||
<span *ngIf="lastRunTime" class="ml-2 text-500">
|
||||
({{ lastRunTime | date : "short" }})
|
||||
</span>
|
||||
<div class="mt-2 text-600" *ngIf="!executing && result">
|
||||
{{ result }}
|
||||
</div>
|
||||
<div class="mt-2 text-red-600" *ngIf="!executing && error">
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</p-card>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно с результатом выполнения -->
|
||||
<p-dialog
|
||||
header="Результат выполнения"
|
||||
[(visible)]="showResultDialog"
|
||||
[modal]="true"
|
||||
[closable]="true"
|
||||
[draggable]="false"
|
||||
[resizable]="false"
|
||||
styleClass="result-dialog"
|
||||
>
|
||||
<div class="dialog-content">
|
||||
<div *ngIf="result" class="mb-3">
|
||||
<h4 class="text-green-600 mb-2">✅ Успешно выполнено</h4>
|
||||
<pre class="bg-gray-50 p-3 border-round text-sm">{{ result }}</pre>
|
||||
</div>
|
||||
<div *ngIf="error" class="mb-3">
|
||||
<h4 class="text-red-600 mb-2">❌ Ошибка</h4>
|
||||
<pre class="bg-red-50 p-3 border-round text-sm text-red-700">{{
|
||||
error
|
||||
}}</pre>
|
||||
</div>
|
||||
<div *ngIf="lastRunTime" class="text-sm text-600">
|
||||
Время выполнения: {{ elapsedMs / 1000 | number : "1.1-1" }}s
|
||||
<br />
|
||||
Завершено: {{ lastRunTime | date : "short" }}
|
||||
</div>
|
||||
</div>
|
||||
<ng-template pTemplate="footer">
|
||||
<button
|
||||
pButton
|
||||
type="button"
|
||||
label="OK"
|
||||
class="p-button-primary"
|
||||
(click)="showResultDialog = false"
|
||||
></button>
|
||||
</ng-template>
|
||||
</p-dialog>
|
||||
|
||||
<!-- Информационное модальное окно -->
|
||||
<p-dialog
|
||||
header="📁 Базовая версия"
|
||||
[(visible)]="showInfoDialog"
|
||||
[modal]="true"
|
||||
[closable]="true"
|
||||
[draggable]="false"
|
||||
[resizable]="false"
|
||||
styleClass="info-dialog"
|
||||
>
|
||||
<div class="dialog-content">
|
||||
<p class="mb-3">
|
||||
Эта версия работает в любом браузере, но файлы загружаются/скачиваются
|
||||
через стандартные диалоги браузера.
|
||||
</p>
|
||||
<div *ngIf="canUseFSA" class="p-3 bg-blue-50 border-round">
|
||||
<p class="text-sm mb-2">
|
||||
💡 <strong>Доступна расширенная версия!</strong>
|
||||
</p>
|
||||
<p class="text-sm mb-3">
|
||||
Ваш браузер поддерживает прямое редактирование файлов на диске.
|
||||
</p>
|
||||
<button
|
||||
pButton
|
||||
type="button"
|
||||
label="Перейти к расширенной версии"
|
||||
class="p-button-success p-button-sm"
|
||||
routerLink="/fsa"
|
||||
(click)="showInfoDialog = false"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
</p-dialog>
|
||||
|
@@ -1,30 +1,22 @@
|
||||
import { Component, inject, Input } from '@angular/core';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators, FormsModule } from '@angular/forms';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||
import { InputTextModule } from 'primeng/inputtext';
|
||||
import { PasswordModule } from 'primeng/password';
|
||||
import { DropdownModule } from 'primeng/dropdown';
|
||||
import { CheckboxModule } from 'primeng/checkbox';
|
||||
import { InputTextareaModule } from 'primeng/inputtextarea';
|
||||
import { ButtonModule } from 'primeng/button';
|
||||
import { CardModule } from 'primeng/card';
|
||||
import { DividerModule } from 'primeng/divider';
|
||||
import { FileUploadModule } from 'primeng/fileupload';
|
||||
import { ProgressBarModule } from 'primeng/progressbar';
|
||||
import { DialogModule } from 'primeng/dialog';
|
||||
import * as yaml from 'js-yaml';
|
||||
import { environment } from '../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-knock-page',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule, RouterModule, ReactiveFormsModule, FormsModule,
|
||||
InputTextModule, PasswordModule, DropdownModule, CheckboxModule,
|
||||
InputTextareaModule, ButtonModule, CardModule, DividerModule,
|
||||
FileUploadModule, ProgressBarModule, DialogModule
|
||||
CommonModule, RouterModule, ReactiveFormsModule,
|
||||
InputTextModule, CheckboxModule, ButtonModule, CardModule,
|
||||
DividerModule, ProgressBarModule
|
||||
],
|
||||
templateUrl: './knock-page.component.html',
|
||||
styleUrls: ['./knock-page.component.scss']
|
||||
@@ -33,352 +25,50 @@ export class KnockPageComponent {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly fb = inject(FormBuilder);
|
||||
|
||||
@Input() enableFSA = false; // Включает File System Access API функциональность
|
||||
@Input() canUseFSA = false; // Доступна ли FSA версия
|
||||
|
||||
// cardHeader = 'Port Knocker GUI';
|
||||
cardHeader = '';
|
||||
animatedTitle = 'Knock Knock Knock on the heaven\'s door ...';
|
||||
showInfoDialog = false;
|
||||
isAnimating = false;
|
||||
showResultDialog = false;
|
||||
|
||||
executing = false;
|
||||
elapsedMs = 0;
|
||||
private timerId: any = null;
|
||||
private startTs = 0;
|
||||
elapsedMs = 0;
|
||||
lastRunTime: Date | null = null;
|
||||
wrongPass = false;
|
||||
selectedFileName: string | null = null;
|
||||
private fileHandle: any = null; // FileSystemFileHandle
|
||||
private isSyncing = false;
|
||||
|
||||
result: string | null = null;
|
||||
error: string | null = null;
|
||||
|
||||
form = this.fb.group({
|
||||
password: ['', Validators.required],
|
||||
mode: ['inline', Validators.required],
|
||||
targets: ['tcp:127.0.0.1:22'],
|
||||
delay: ['1s'],
|
||||
verbose: [true],
|
||||
waitConnection: [false],
|
||||
gateway: [''],
|
||||
configYAML: [''],
|
||||
serverFilePath: ['']
|
||||
targets: ['tcp:127.0.0.1:22', Validators.required],
|
||||
delay: ['1s', Validators.required],
|
||||
waitConnection: [false]
|
||||
});
|
||||
|
||||
constructor() {
|
||||
// Загружаем сохраненное состояние из localStorage
|
||||
this.loadStateFromLocalStorage();
|
||||
|
||||
// Запускаем анимацию заголовка
|
||||
this.startTitleAnimation();
|
||||
|
||||
// Сбрасываем индикатор неверного пароля при изменении поля
|
||||
this.form.get('password')?.valueChanges.subscribe(() => {
|
||||
this.wrongPass = false;
|
||||
});
|
||||
|
||||
// React on YAML text changes: extract path and sync to serverFilePath
|
||||
this.form.get('configYAML')?.valueChanges.subscribe((val) => {
|
||||
if (this.isSyncing) return;
|
||||
if (this.isInlineMode() || this.isYamlEncrypted()) return;
|
||||
try {
|
||||
const p = this.extractPathFromYaml(String(val ?? ''));
|
||||
const currentPath = this.form.value.serverFilePath || '';
|
||||
if (p && p !== currentPath) {
|
||||
this.isSyncing = true;
|
||||
this.form.patchValue({ serverFilePath: p });
|
||||
this.isSyncing = false;
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
|
||||
// React on serverFilePath changes: update YAML path field
|
||||
this.form.get('serverFilePath')?.valueChanges.subscribe((newPath) => {
|
||||
if (this.isSyncing) return;
|
||||
this.onServerPathChange(newPath || '');
|
||||
});
|
||||
|
||||
// Подписка на изменение режима для автоматического преобразования
|
||||
this.setupModeConversion();
|
||||
|
||||
// Подписки на изменения полей для автосохранения в localStorage
|
||||
this.setupAutoSave();
|
||||
|
||||
// File System Access API detection (для обратной совместимости)
|
||||
// Логика FSA теперь находится в отдельных компонентах
|
||||
}
|
||||
|
||||
private authHeader(pass: string) {
|
||||
// Basic auth с пользователем "knocker"
|
||||
const token = btoa(`knocker:${pass}`);
|
||||
return { Authorization: `Basic ${token}` };
|
||||
}
|
||||
|
||||
execute() {
|
||||
this.error = null;
|
||||
this.result = null;
|
||||
this.wrongPass = false;
|
||||
if (this.form.invalid) return;
|
||||
|
||||
const v = this.form.value;
|
||||
const body: any = {
|
||||
targets: v.targets,
|
||||
delay: v.delay,
|
||||
verbose: v.verbose,
|
||||
waitConnection: v.waitConnection,
|
||||
gateway: v.gateway,
|
||||
waitConnection: v.waitConnection
|
||||
};
|
||||
if (v.mode === 'yaml') {
|
||||
body.config_yaml = v.configYAML;
|
||||
delete body.targets;
|
||||
delete body.delay;
|
||||
}
|
||||
|
||||
this.executing = true;
|
||||
this.startTimer();
|
||||
this.http.post('/api/v1/knock-actions/execute', body, {
|
||||
headers: this.authHeader(v.password || '')
|
||||
}).subscribe({
|
||||
|
||||
// Без обязательного Basic-Auth: заголовок не добавляется, если пароль не требуется
|
||||
this.http.post('/api/v1/knock-actions/execute', body).subscribe({
|
||||
next: () => {
|
||||
this.executing = false;
|
||||
this.stopTimer();
|
||||
this.lastRunTime = new Date();
|
||||
this.result = `Done in ${(this.elapsedMs/1000).toFixed(2)}s`;
|
||||
this.showResultDialog = true;
|
||||
this.result = `Done in ${(this.elapsedMs / 1000).toFixed(2)}s`;
|
||||
},
|
||||
error: (e: HttpErrorResponse) => {
|
||||
this.executing = false;
|
||||
this.stopTimer();
|
||||
if (e.status === 401) {
|
||||
this.wrongPass = true;
|
||||
}
|
||||
this.error = (e.error?.error) || e.message;
|
||||
this.showResultDialog = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
encrypt() {
|
||||
this.error = null;
|
||||
this.result = null;
|
||||
const v = this.form.value;
|
||||
if (this.isInlineMode() || this.isYamlEncrypted() || !v.password || this.wrongPass) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем есть ли path в YAML самом
|
||||
const pathFromYaml = this.getPathFromYaml(v.configYAML || '');
|
||||
const serverFilePath = (this.form.value.serverFilePath || '').trim();
|
||||
|
||||
let url: string;
|
||||
let body: any;
|
||||
|
||||
if (pathFromYaml) {
|
||||
// Если path в YAML - используем /encrypt, сервер сам найдет path в YAML
|
||||
url = '/api/v1/knock-actions/encrypt';
|
||||
body = { yaml: v.configYAML };
|
||||
} else if (serverFilePath) {
|
||||
// Если path только в serverFilePath - используем /encrypt-file
|
||||
url = '/api/v1/knock-actions/encrypt-file';
|
||||
body = { path: serverFilePath };
|
||||
} else {
|
||||
// Нет пути - обычное шифрование содержимого
|
||||
url = '/api/v1/knock-actions/encrypt';
|
||||
body = { yaml: v.configYAML };
|
||||
}
|
||||
|
||||
this.http.post(url, body, {
|
||||
headers: this.authHeader(v.password || '')
|
||||
}).subscribe({
|
||||
next: async (res: any) => {
|
||||
const encrypted: string = res.encrypted || '';
|
||||
|
||||
// Всегда обновляем YAML поле зашифрованным содержимым
|
||||
this.form.patchValue({ configYAML: encrypted });
|
||||
|
||||
if (pathFromYaml) {
|
||||
this.result = `Encrypted (YAML path: ${pathFromYaml})`;
|
||||
// НЕ сохраняем файл клиентом - сервер уже записал по path из YAML
|
||||
} else if (serverFilePath) {
|
||||
this.result = `Encrypted (server path: ${serverFilePath})`;
|
||||
// НЕ сохраняем файл клиентом - сервер записал по serverFilePath
|
||||
} else {
|
||||
this.result = 'Encrypted';
|
||||
// Только сохраняем в файл если НЕТ серверного пути
|
||||
await this.saveBackToFileIfPossible(encrypted, this.selectedFileName);
|
||||
}
|
||||
},
|
||||
error: (e: HttpErrorResponse) => this.error = (e.error && e.error.error) || e.message
|
||||
});
|
||||
}
|
||||
|
||||
decrypt() {
|
||||
this.error = null;
|
||||
this.result = null;
|
||||
const v = this.form.value;
|
||||
if (this.isInlineMode() || !this.isYamlEncrypted() || !v.password || this.wrongPass) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Для зашифрованного YAML поле serverFilePath недоступно - используем только decrypt
|
||||
const url = '/api/v1/knock-actions/decrypt';
|
||||
const body = { encrypted: v.configYAML as string };
|
||||
|
||||
this.http.post(url, body, {
|
||||
headers: this.authHeader(v.password || '')
|
||||
}).subscribe({
|
||||
next: async (res: any) => {
|
||||
const plain: string = res.yaml || '';
|
||||
this.form.patchValue({ configYAML: plain });
|
||||
this.result = 'Decrypted';
|
||||
|
||||
// Извлекаем path из расшифрованного YAML и обновляем serverFilePath
|
||||
const pathFromDecrypted = this.getPathFromYaml(plain);
|
||||
if (pathFromDecrypted) {
|
||||
this.isSyncing = true;
|
||||
this.form.patchValue({ serverFilePath: pathFromDecrypted });
|
||||
this.isSyncing = false;
|
||||
this.result += ` (found path: ${pathFromDecrypted})`;
|
||||
}
|
||||
|
||||
// НЕ делаем download - сервер уже обработал файл согласно path в YAML
|
||||
},
|
||||
error: (e: HttpErrorResponse) => this.error = (e.error && e.error.error) || e.message
|
||||
});
|
||||
}
|
||||
|
||||
onFileSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (!input.files || input.files.length === 0) return;
|
||||
const file = input.files[0];
|
||||
this.selectedFileName = file.name;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const text = String(reader.result || '');
|
||||
this.form.patchValue({ configYAML: text, mode: 'yaml' });
|
||||
// Sync path from YAML into serverFilePath
|
||||
const p = this.extractPathFromYaml(text);
|
||||
if (p) {
|
||||
this.isSyncing = true;
|
||||
this.form.patchValue({ serverFilePath: p });
|
||||
this.isSyncing = false;
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
this.fileHandle = null; // обычная загрузка не даёт handle на запись
|
||||
}
|
||||
|
||||
downloadYaml() {
|
||||
const yaml = this.form.value.configYAML || '';
|
||||
this.triggerDownload('config.yaml', yaml);
|
||||
}
|
||||
|
||||
downloadResult() {
|
||||
const content = this.result || this.form.value.configYAML || '';
|
||||
const name = (content || '').startsWith('ENCRYPTED:') ? 'config.encrypted' : 'config.yaml';
|
||||
this.triggerDownload(name, content);
|
||||
}
|
||||
|
||||
onFileUpload(event: any) {
|
||||
const files: File[] = event?.files || event?.currentFiles || [];
|
||||
if (!files.length) return;
|
||||
const file = files[0];
|
||||
this.selectedFileName = file.name;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const text = String(reader.result || '');
|
||||
this.form.patchValue({ configYAML: text, mode: 'yaml' });
|
||||
const p = this.extractPathFromYaml(text);
|
||||
if (!p) {
|
||||
return;
|
||||
}
|
||||
this.isSyncing = true;
|
||||
this.form.patchValue({ serverFilePath: p });
|
||||
this.isSyncing = false;
|
||||
};
|
||||
reader.readAsText(file);
|
||||
this.fileHandle = null;
|
||||
}
|
||||
|
||||
async openFileWithWriteAccess() {
|
||||
try {
|
||||
const w: any = window as any;
|
||||
if (!w || typeof w.showOpenFilePicker !== 'function') {
|
||||
this.error = 'File System Access API is not supported by this browser.';
|
||||
return;
|
||||
}
|
||||
const [handle] = await w.showOpenFilePicker({
|
||||
types: [{ description: 'YAML/Encrypted', accept: { 'text/plain': ['.yaml', '.yml', '.encrypted', '.txt'] } }]
|
||||
});
|
||||
this.fileHandle = handle;
|
||||
const file = await handle.getFile();
|
||||
this.selectedFileName = file.name;
|
||||
const text = await file.text();
|
||||
this.form.patchValue({ configYAML: text, mode: 'yaml' });
|
||||
const p = this.extractPathFromYaml(text);
|
||||
if (p) {
|
||||
this.isSyncing = true;
|
||||
this.form.patchValue({ serverFilePath: p });
|
||||
this.isSyncing = false;
|
||||
}
|
||||
this.result = `Opened: ${file.name}`;
|
||||
this.error = null;
|
||||
} catch (e: any) {
|
||||
// user cancelled or error
|
||||
}
|
||||
}
|
||||
|
||||
// Helpers for UI state
|
||||
isInlineMode(): boolean {
|
||||
return (this.form.value.mode === 'inline');
|
||||
}
|
||||
isYamlEncrypted(): boolean {
|
||||
const s = (this.form.value.configYAML || '').toString().trim();
|
||||
return s.startsWith('ENCRYPTED:');
|
||||
}
|
||||
|
||||
private triggerDownload(filename: string, text: string) {
|
||||
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
private async saveBackToFileIfPossible(content: string, filename: string | null) {
|
||||
try {
|
||||
const w: any = window as any;
|
||||
if (this.fileHandle && typeof this.fileHandle.createWritable === 'function') {
|
||||
const writable = await this.fileHandle.createWritable();
|
||||
await writable.write(content);
|
||||
await writable.close();
|
||||
return;
|
||||
}
|
||||
if (w && typeof w.showSaveFilePicker === 'function') {
|
||||
const handle = await w.showSaveFilePicker({
|
||||
suggestedName: filename || 'config.yaml',
|
||||
types: [{ description: 'YAML/Encrypted', accept: { 'text/plain': ['.yaml', '.yml', '.encrypted', '.txt'] } }]
|
||||
});
|
||||
const writable = await handle.createWritable();
|
||||
await writable.write(content);
|
||||
await writable.close();
|
||||
return;
|
||||
} else if (filename) {
|
||||
this.triggerDownload(filename, content);
|
||||
} else {
|
||||
this.triggerDownload('config.yaml', content);
|
||||
}
|
||||
} catch {
|
||||
if (filename) {
|
||||
this.triggerDownload(filename, content);
|
||||
} else {
|
||||
this.triggerDownload('config.yaml', content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private startTimer() {
|
||||
this.elapsedMs = 0;
|
||||
this.startTs = Date.now();
|
||||
@@ -401,380 +91,6 @@ export class KnockPageComponent {
|
||||
this.timerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// YAML path helpers
|
||||
private getPathFromYaml(text: string): string {
|
||||
return this.extractPathFromYaml(text);
|
||||
}
|
||||
|
||||
private extractPathFromYaml(text: string): string {
|
||||
try {
|
||||
const doc: any = yaml.load(text);
|
||||
if (doc && typeof doc === 'object' && typeof doc.path === 'string') {
|
||||
return doc.path;
|
||||
}
|
||||
} catch {}
|
||||
return '';
|
||||
}
|
||||
|
||||
onServerPathChange(newPath: string) {
|
||||
if (this.isInlineMode() || this.isYamlEncrypted()) return;
|
||||
environment.log('onServerPathChange', newPath);
|
||||
const current = String(this.form.value.configYAML || '');
|
||||
try {
|
||||
const doc: any = current.trim() ? yaml.load(current) : {};
|
||||
if (!doc || typeof doc !== 'object') return;
|
||||
(doc as any).path = newPath || '';
|
||||
this.isSyncing = true;
|
||||
this.form.patchValue({ configYAML: yaml.dump(doc, { lineWidth: 120 }) }, { emitEvent: true });
|
||||
this.isSyncing = false;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// LocalStorage functionality
|
||||
private readonly STORAGE_KEY = 'knocker-ui-state';
|
||||
|
||||
private loadStateFromLocalStorage() {
|
||||
try {
|
||||
const saved = localStorage.getItem(this.STORAGE_KEY);
|
||||
if (!saved) return;
|
||||
|
||||
const state = JSON.parse(saved);
|
||||
environment.log('Loading saved state:', state);
|
||||
|
||||
// Применяем сохраненные значения к форме
|
||||
const patchData: any = {};
|
||||
|
||||
if (state.mode !== undefined) patchData.mode = state.mode;
|
||||
if (state.targets !== undefined) patchData.targets = state.targets;
|
||||
if (state.delay !== undefined) patchData.delay = state.delay;
|
||||
if (state.verbose !== undefined) patchData.verbose = state.verbose;
|
||||
if (state.waitConnection !== undefined) patchData.waitConnection = state.waitConnection;
|
||||
if (state.configYAML !== undefined) patchData.configYAML = state.configYAML;
|
||||
|
||||
if (Object.keys(patchData).length > 0) {
|
||||
this.form.patchValue(patchData);
|
||||
|
||||
// Если загружен YAML, извлекаем path и устанавливаем в serverFilePath
|
||||
if (state.configYAML) {
|
||||
const pathFromYaml = this.getPathFromYaml(state.configYAML);
|
||||
if (pathFromYaml) {
|
||||
this.isSyncing = true;
|
||||
this.form.patchValue({ serverFilePath: pathFromYaml });
|
||||
this.isSyncing = false;
|
||||
environment.log('Extracted path from loaded YAML:', pathFromYaml);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load state from localStorage:', e);
|
||||
}
|
||||
}
|
||||
|
||||
private saveStateToLocalStorage() {
|
||||
try {
|
||||
const formValue = this.form.value;
|
||||
const state = {
|
||||
mode: formValue.mode,
|
||||
targets: formValue.targets,
|
||||
delay: formValue.delay,
|
||||
verbose: formValue.verbose,
|
||||
waitConnection: formValue.waitConnection,
|
||||
configYAML: formValue.configYAML
|
||||
};
|
||||
|
||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(state));
|
||||
environment.log('State saved to localStorage:', state);
|
||||
} catch (e) {
|
||||
console.warn('Failed to save state to localStorage:', e);
|
||||
}
|
||||
}
|
||||
|
||||
private setupAutoSave() {
|
||||
// Подписываемся на изменения нужных полей
|
||||
const fieldsToWatch = ['mode', 'targets', 'delay', 'verbose', 'waitConnection', 'configYAML'];
|
||||
|
||||
fieldsToWatch.forEach(fieldName => {
|
||||
this.form.get(fieldName)?.valueChanges.subscribe(() => {
|
||||
// Небольшая задержка, чтобы не сохранять на каждое нажатие клавиши
|
||||
setTimeout(() => this.saveStateToLocalStorage(), 300);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Автоматическое преобразование между режимами
|
||||
private setupModeConversion() {
|
||||
let previousMode = this.form.value.mode;
|
||||
|
||||
this.form.get('mode')?.valueChanges.subscribe((newMode) => {
|
||||
if (this.isSyncing) return;
|
||||
|
||||
environment.log(`Mode changed from ${previousMode} to ${newMode}`);
|
||||
|
||||
if (previousMode === 'inline' && newMode === 'yaml') {
|
||||
this.handleModeChangeToYaml();
|
||||
} else if (previousMode === 'yaml' && newMode === 'inline') {
|
||||
this.handleModeChangeToInline();
|
||||
}
|
||||
|
||||
previousMode = newMode;
|
||||
});
|
||||
}
|
||||
|
||||
private handleModeChangeToYaml() {
|
||||
try {
|
||||
// Проверяем есть ли сохраненный YAML в localStorage
|
||||
const savedYaml = this.getSavedConfigYAML();
|
||||
|
||||
if (savedYaml?.trim()) {
|
||||
// Используем сохраненный YAML
|
||||
environment.log('Using saved YAML from localStorage');
|
||||
this.isSyncing = true;
|
||||
this.form.patchValue({ configYAML: savedYaml });
|
||||
this.isSyncing = false;
|
||||
} else {
|
||||
// Конвертируем из inline
|
||||
environment.log('Converting inline to YAML');
|
||||
this.convertInlineToYaml();
|
||||
}
|
||||
|
||||
// После установки YAML (из localStorage или конвертации) извлекаем path
|
||||
setTimeout(() => this.extractAndSetServerPath(), 100);
|
||||
|
||||
} catch (e) {
|
||||
console.warn('Failed to handle mode change to YAML:', e);
|
||||
}
|
||||
}
|
||||
|
||||
private handleModeChangeToInline() {
|
||||
try {
|
||||
// Проверяем есть ли сохраненные inline значения в localStorage
|
||||
const savedTargets = this.getSavedTargets();
|
||||
|
||||
if (savedTargets && savedTargets.trim()) {
|
||||
// Используем сохраненные inline значения
|
||||
environment.log('Using saved inline targets from localStorage');
|
||||
this.isSyncing = true;
|
||||
this.form.patchValue({ targets: savedTargets });
|
||||
this.isSyncing = false;
|
||||
} else {
|
||||
// Конвертируем из YAML
|
||||
environment.log('Converting YAML to inline');
|
||||
this.convertYamlToInline();
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.warn('Failed to handle mode change to inline:', e);
|
||||
}
|
||||
}
|
||||
|
||||
private getSavedConfigYAML(): string | null {
|
||||
try {
|
||||
const saved = localStorage.getItem(this.STORAGE_KEY);
|
||||
if (!saved) return null;
|
||||
const state = JSON.parse(saved);
|
||||
return state.configYAML || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private getSavedTargets(): string | null {
|
||||
try {
|
||||
const saved = localStorage.getItem(this.STORAGE_KEY);
|
||||
if (!saved) return null;
|
||||
const state = JSON.parse(saved);
|
||||
return state.targets || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private extractAndSetServerPath() {
|
||||
try {
|
||||
const yamlContent = this.form.value.configYAML || '';
|
||||
if (!yamlContent.trim()) return;
|
||||
|
||||
const config: any = yaml.load(yamlContent);
|
||||
if (config && typeof config === 'object' && config.path) {
|
||||
environment.log('Extracted path from YAML:', config.path);
|
||||
this.isSyncing = true;
|
||||
this.form.patchValue({ serverFilePath: config.path });
|
||||
this.isSyncing = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to extract path from YAML:', e);
|
||||
}
|
||||
}
|
||||
|
||||
private convertInlineToYaml() {
|
||||
try {
|
||||
const formValue = this.form.value;
|
||||
const targets = (formValue.targets || '').split(';').filter(t => t.trim());
|
||||
|
||||
// Создаем YAML конфигурацию из inline параметров
|
||||
const config: any = {
|
||||
targets: targets.map(target => {
|
||||
const [protocol, address] = target.trim().split(':');
|
||||
const [host, port] = address ? address.split(':') : ['', ''];
|
||||
|
||||
return {
|
||||
protocol: protocol || 'tcp',
|
||||
host: host || '127.0.0.1',
|
||||
ports: [parseInt(port) || 22],
|
||||
wait_connection: formValue.waitConnection || false
|
||||
};
|
||||
}),
|
||||
delay: formValue.delay || '1s'
|
||||
};
|
||||
|
||||
const yamlString = yaml.dump(config, { lineWidth: 120 });
|
||||
|
||||
this.isSyncing = true;
|
||||
this.form.patchValue({ configYAML: yamlString });
|
||||
this.isSyncing = false;
|
||||
|
||||
environment.log('Converted inline to YAML:', config);
|
||||
} catch (e) {
|
||||
console.warn('Failed to convert inline to YAML:', e);
|
||||
}
|
||||
}
|
||||
|
||||
private convertYamlToInline() {
|
||||
try {
|
||||
const yamlContent = this.form.value.configYAML || '';
|
||||
if (!yamlContent.trim()) {
|
||||
// Если YAML пустой, используем значения по умолчанию
|
||||
this.isSyncing = true;
|
||||
this.form.patchValue({
|
||||
targets: 'tcp:127.0.0.1:22',
|
||||
delay: '1s',
|
||||
waitConnection: false
|
||||
});
|
||||
this.isSyncing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const config: any = yaml.load(yamlContent);
|
||||
if (!config || !config.targets || !Array.isArray(config.targets)) {
|
||||
console.warn('Invalid YAML structure for conversion');
|
||||
return;
|
||||
}
|
||||
|
||||
// Конвертируем targets в строку - создаем отдельную строку для каждого порта
|
||||
const targetStrings: string[] = [];
|
||||
config.targets.forEach((target: any) => {
|
||||
const protocol = target.protocol || 'tcp';
|
||||
const host = target.host || '127.0.0.1';
|
||||
// Поддерживаем как ports (массив), так и port (единственное число) для обратной совместимости
|
||||
const ports = target.ports || [target.port] || [22];
|
||||
|
||||
if (Array.isArray(ports)) {
|
||||
// Создаем отдельную строку для каждого порта
|
||||
ports.forEach(port => {
|
||||
targetStrings.push(`${protocol}:${host}:${port}`);
|
||||
});
|
||||
} else {
|
||||
targetStrings.push(`${protocol}:${host}:${ports}`);
|
||||
}
|
||||
});
|
||||
|
||||
const targetsString = targetStrings.join(';');
|
||||
const delay = config.delay || '1s';
|
||||
|
||||
// Берем wait_connection из первой цели (если есть)
|
||||
const waitConnection = config.targets[0]?.wait_connection || false;
|
||||
|
||||
this.isSyncing = true;
|
||||
this.form.patchValue({
|
||||
targets: targetsString,
|
||||
delay: delay,
|
||||
waitConnection: waitConnection
|
||||
});
|
||||
this.isSyncing = false;
|
||||
|
||||
environment.log('Converted YAML to inline:', { targets: targetsString, delay, waitConnection });
|
||||
} catch (e) {
|
||||
console.warn('Failed to convert YAML to inline:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Публичный метод для очистки сохраненного состояния (опционально)
|
||||
clearSavedState() {
|
||||
try {
|
||||
localStorage.removeItem(this.STORAGE_KEY);
|
||||
environment.log('Saved state cleared from localStorage');
|
||||
|
||||
// Сбрасываем форму к значениям по умолчанию
|
||||
this.form.patchValue({
|
||||
mode: 'inline',
|
||||
targets: 'tcp:127.0.0.1:22',
|
||||
delay: '1s',
|
||||
verbose: true,
|
||||
waitConnection: false,
|
||||
configYAML: ''
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Failed to clear saved state:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Анимация заголовка
|
||||
private startTitleAnimation() {
|
||||
if (this.isAnimating) return;
|
||||
this.isAnimating = true;
|
||||
|
||||
// Первая анимация: по буквам
|
||||
this.animateByLetters();
|
||||
}
|
||||
|
||||
private animateByLetters() {
|
||||
let currentIndex = 0;
|
||||
const letters = this.animatedTitle.split('');
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (currentIndex < letters.length) {
|
||||
this.cardHeader = letters.slice(0, currentIndex + 1).join('');
|
||||
currentIndex++;
|
||||
} else {
|
||||
clearInterval(interval);
|
||||
// Ждем 2 секунды и начинаем исчезать
|
||||
setTimeout(() => {
|
||||
this.fadeOutTitle();
|
||||
}, 2000);
|
||||
}
|
||||
}, 100); // 100ms между буквами
|
||||
}
|
||||
|
||||
private fadeOutTitle() {
|
||||
let opacity = 1;
|
||||
const fadeInterval = setInterval(() => {
|
||||
opacity -= 0.1;
|
||||
if (opacity <= 0) {
|
||||
clearInterval(fadeInterval);
|
||||
this.cardHeader = '';
|
||||
// Ждем 1 секунду и начинаем анимацию по словам
|
||||
setTimeout(() => {
|
||||
this.animateByWords();
|
||||
}, 1000);
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
private animateByWords() {
|
||||
const words = this.animatedTitle.split(' ');
|
||||
let currentIndex = 0;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (currentIndex < words.length) {
|
||||
this.cardHeader = words.slice(0, currentIndex + 1).join(' ');
|
||||
currentIndex++;
|
||||
} else {
|
||||
clearInterval(interval);
|
||||
this.isAnimating = false;
|
||||
}
|
||||
}, 300); // 300ms между словами
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
Reference in New Issue
Block a user