diff --git a/Makefile b/Makefile
index ef62d66..49f02d5 100644
--- a/Makefile
+++ b/Makefile
@@ -43,7 +43,7 @@ back-build: embed-ui back-deps
cd $(BACK_DIR) && go build -o knocker-serve .
run: back-build
cd $(BACK_DIR) && GO_KNOCKER_SERVE_PASS=$(PASS) GO_KNOCKER_SERVE_PORT=$(PORT) ./knocker-serve {+-v+} serve
run-bg: back-build
cd $(BACK_DIR) && nohup env GO_KNOCKER_SERVE_PASS=$(PASS) GO_KNOCKER_SERVE_PORT=$(PORT) ./knocker-serve serve > /tmp/knocker.log 2>&1 & echo $$! && sleep 1 && tail -n +1 /tmp/knocker.log | sed -n '1,60p'
diff --git a/back/cmd/knock_routes.go b/back/cmd/knock_routes.go
index 1b30229..6596def 100644
--- a/back/cmd/knock_routes.go
+++ b/back/cmd/knock_routes.go
@@ -58,7 +58,7 @@ func setupKnockRoutes(api *gin.RouterGroup) {
c.JSON(400, gin.H{"error": "targets is required in inline mode"})
return
}
config, err := parseInlineTargetsWithWait(req.Targets, req.Delay, [-req.WaitConnection)-]{+req.WaitConnection, req.Gateway)+}
if err != nil {
c.JSON(400, gin.H{"error": fmt.Sprintf("invalid targets: %v", err)})
return
@@ -71,7 +71,7 @@ func setupKnockRoutes(api *gin.RouterGroup) {
}
}
if err := knocker.ExecuteWithConfig(&config, {+true ||+} req.Verbose, req.WaitConnection); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
@@ -81,7 +81,7 @@ func setupKnockRoutes(api *gin.RouterGroup) {
}
// parseInlineTargetsWithWait парсит inline строку целей в Config с поддержкой waitConnection
func parseInlineTargetsWithWait(targets, delay string, waitConnection [-bool)-]{+bool, gateway string)+} (internal.Config, error) {
var config internal.Config
// Парсим targets
@@ -93,13 +93,18 @@ func parseInlineTargetsWithWait(targets, delay string, waitConnection bool) (int
}
parts := strings.Split(targetStr, ":")
if [-len(parts) !=-]{+!(len(parts) ==+} 3 {+|| len(parts) == 4)+} {
return config, fmt.Errorf("invalid target format: %s (expected [-protocol:host:port)",-]{+protocol:host:port or protocol:host:port:gateway)",+} targetStr)
}
protocol := strings.TrimSpace(parts[0])
host := strings.TrimSpace(parts[1])
portStr := strings.TrimSpace(parts[2])
{+if len(parts) == 4 {+}
{+ gateway = strings.TrimSpace(parts[3])+}
{+ }+}
if protocol != "tcp" && protocol != "udp" {
return config, fmt.Errorf("unsupported protocol: %s (only tcp/udp supported)", protocol)
@@ -128,6 +133,7 @@ func parseInlineTargetsWithWait(targets, delay string, waitConnection bool) (int
Ports: []int{port},
Delay: targetDelay,
WaitConnection: waitConnection,
{+Gateway: gateway,+}
}
config.Targets = append(config.Targets, target)
diff --git a/back/go.mod b/back/go.mod
index eb15378..c4a9a82 100644
--- a/back/go.mod
+++ b/back/go.mod
@@ -6,6 +6,7 @@ require (
github.com/gin-contrib/cors v1.7.2
github.com/gin-gonic/gin v1.10.0
github.com/spf13/cobra v1.8.0
{+golang.org/x/sys v0.20.0+}
gopkg.in/yaml.v3 v3.0.1
)
@@ -35,7 +36,6 @@ require (
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.23.0 // indirect
golang.org/x/net v0.25.0 // indirect
[- golang.org/x/sys v0.20.0 // indirect-]
golang.org/x/text v0.15.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
)
diff --git a/back/internal/knocker.go b/back/internal/knocker.go
index b946717..2aa66c4 100644
--- a/back/internal/knocker.go
+++ b/back/internal/knocker.go
@@ -10,9 +10,12 @@ import (
"math/rand"
"net"
"os"
{+"regexp"+}
"strings"
{+"syscall"+}
"time"
{+"golang.org/x/sys/unix"+}
"gopkg.in/yaml.v3"
)
@@ -278,14 +281,18 @@ func (pk *PortKnocker) knockTarget(target Target, verbose bool) error {
}
// Вычисляем таймаут как половину интервала между пакетами
timeout := [-time.Duration(target.Delay) / 2-]
[- if timeout < 100*time.Millisecond {-]
[- timeout = 100 * time.Millisecond-]{+max(time.Duration(target.Delay)/2,+}
// минимальный таймаут
[-}-]{+100*time.Millisecond)+}
for i, port := range target.Ports {
if verbose {
{+if target.Gateway != "" {+}
{+ fmt.Printf(" Отправка пакета на %s:%d (%s) через шлюз %s\n", target.Host, port, protocol, target.Gateway)+}
{+ } else {+}
fmt.Printf(" Отправка пакета на %s:%d (%s)\n", target.Host, port, protocol)
{+}+}
}
if err := pk.sendPacket(target.Host, port, protocol, target.WaitConnection, timeout, target.Gateway); err != nil {
@@ -314,7 +321,8 @@ func (pk *PortKnocker) knockTarget(target Target, verbose bool) error {
}
// sendPacket отправляет один пакет на указанный хост и порт
{+// sendPacket_backup — резервная копия прежней реализации+}
func (pk *PortKnocker) [-sendPacket(host-]{+SendPacket_backup(host+} string, port int, protocol string, waitConnection bool, timeout time.Duration, gateway string) error {
address := net.JoinHostPort(host, fmt.Sprintf("%d", port))
var conn net.Conn
@@ -381,8 +389,8 @@ func (pk *PortKnocker) sendPacket(host string, port int, protocol string, waitCo
return nil
}
// [-sendPacketWithoutConnection отправляет пакет без установления соединения-]{+sendPacketWithoutConnection_backup — резервная копия прежней реализации+}
func (pk *PortKnocker) [-sendPacketWithoutConnection(host-]{+SendPacketWithoutConnection_backup(host+} string, port int, protocol string, localAddr net.Addr) error {
address := net.JoinHostPort(host, fmt.Sprintf("%d", port))
switch protocol {
@@ -440,6 +448,155 @@ func (pk *PortKnocker) sendPacketWithoutConnection(host string, port int, protoc
return nil
}
{+// parseGateway возвращает локальные адреса для TCP/UDP и (опционально) имя интерфейса,+}
{+// если в gateway передано, например, "eth1". Также поддерживается IP[:port].+}
{+func parseGateway(gateway string) (tcpLocal *net.TCPAddr, udpLocal *net.UDPAddr, ifaceName string, err error) {+}
{+ gateway = strings.TrimSpace(gateway)+}
{+ if gateway == "" {+}
{+ return nil, nil, "", nil+}
{+ }+}
{+ // Если это похоже на имя интерфейса (буквы/цифры/дефисы, без точного IP)+}
{+ isIfaceName := regexp.MustCompile(`^[A-Za-z0-9_-]+$`).MatchString(gateway) && net.ParseIP(gateway) == nil+}
{+ if isIfaceName {+}
{+ // Привязка по интерфейсу. LocalAddr оставим пустым — маршрут выберется ядром,+}
{+ // а SO_BINDTODEVICE закрепит интерфейс.+}
{+ return nil, nil, gateway, nil+}
{+ }+}
{+ // Иначе трактуем как IP[:port]+}
{+ host := gateway+}
{+ if !strings.Contains(gateway, ":") {+}
{+ host = gateway + ":0"+}
{+ }+}
{+ tcpLocal, err = net.ResolveTCPAddr("tcp", host)+}
{+ if err != nil {+}
{+ return nil, nil, "", fmt.Errorf("не удалось разрешить локальный TCP адрес %s: %w", host, err)+}
{+ }+}
{+ udpLocal, err = net.ResolveUDPAddr("udp", host)+}
{+ if err != nil {+}
{+ return nil, nil, "", fmt.Errorf("не удалось разрешить локальный UDP адрес %s: %w", host, err)+}
{+ }+}
{+ return tcpLocal, udpLocal, "", nil+}
{+}+}
{+// buildDialer создаёт net.Dialer с опциональной привязкой к LocalAddr и интерфейсу (Linux SO_BINDTODEVICE)+}
{+func buildDialer(protocol string, tcpLocal *net.TCPAddr, udpLocal *net.UDPAddr, timeout time.Duration, ifaceName string) *net.Dialer {+}
{+ d := &net.Dialer{Timeout: timeout}+}
{+ if protocol == "tcp" && tcpLocal != nil {+}
{+ d.LocalAddr = tcpLocal+}
{+ }+}
{+ if protocol == "udp" && udpLocal != nil {+}
{+ d.LocalAddr = udpLocal+}
{+ }+}
{+ if strings.TrimSpace(ifaceName) != "" {+}
{+ d.Control = func(network, address string, c syscall.RawConn) error {+}
{+ var ctrlErr error+}
{+ err := c.Control(func(fd uintptr) {+}
{+ // Привязка сокета к интерфейсу по имени+}
{+ ctrlErr = unix.SetsockoptString(int(fd), unix.SOL_SOCKET, unix.SO_BINDTODEVICE, ifaceName)+}
{+ })+}
{+ if err != nil {+}
{+ return err+}
{+ }+}
{+ return ctrlErr+}
{+ }+}
{+ }+}
{+ return d+}
{+}+}
{+// sendPacket — обновлённая реализация с поддержкой UDPAddr и SO_BINDTODEVICE+}
{+func (pk *PortKnocker) sendPacket(host string, port int, protocol string, waitConnection bool, timeout time.Duration, gateway string) error {+}
{+ address := net.JoinHostPort(host, fmt.Sprintf("%d", port))+}
{+ tcpLocal, udpLocal, ifaceName, err := parseGateway(gateway)+}
{+ if err != nil {+}
{+ return err+}
{+ }+}
{+ switch protocol {+}
{+ case "tcp":+}
{+ dialer := buildDialer("tcp", tcpLocal, nil, timeout, ifaceName)+}
{+ conn, err := dialer.Dial("tcp", address)+}
{+ if err != nil {+}
{+ if waitConnection {+}
{+ return fmt.Errorf("не удалось подключиться к %s: %w", address, err)+}
{+ }+}
{+ // без ожидания — пробуем best-effort отправку+}
{+ return pk.sendPacketWithoutConnection(host, port, protocol, dialer.LocalAddr)+}
{+ }+}
{+ defer conn.Close()+}
{+ _, err = conn.Write([]byte{})+}
{+ if err != nil {+}
{+ return fmt.Errorf("не удалось отправить пакет: %w", err)+}
{+ }+}
{+ return nil+}
{+ case "udp":+}
{+ dialer := buildDialer("udp", nil, udpLocal, timeout, ifaceName)+}
{+ conn, err := dialer.Dial("udp", address)+}
{+ if err != nil {+}
{+ if waitConnection {+}
{+ return fmt.Errorf("не удалось подключиться к %s: %w", address, err)+}
{+ }+}
{+ return pk.sendPacketWithoutConnection(host, port, protocol, dialer.LocalAddr)+}
{+ }+}
{+ defer conn.Close()+}
{+ _, err = conn.Write([]byte{})+}
{+ if err != nil {+}
{+ return fmt.Errorf("не удалось отправить пакет: %w", err)+}
{+ }+}
{+ return nil+}
{+ default:+}
{+ return fmt.Errorf("неподдерживаемый протокол: %s", protocol)+}
{+ }+}
{+}+}
{+// sendPacketWithoutConnection — обновлённая реализация best-effort без ожидания соединения+}
{+func (pk *PortKnocker) sendPacketWithoutConnection(host string, port int, protocol string, localAddr net.Addr) error {+}
{+ address := net.JoinHostPort(host, fmt.Sprintf("%d", port))+}
{+ switch protocol {+}
{+ case "udp":+}
{+ // Используем Dialer с коротким таймаутом, локальный адрес может быть *net.UDPAddr+}
{+ var udpLocal *net.UDPAddr+}
{+ if la, ok := localAddr.(*net.UDPAddr); ok {+}
{+ udpLocal = la+}
{+ }+}
{+ d := &net.Dialer{Timeout: 200 * time.Millisecond}+}
{+ if udpLocal != nil {+}
{+ d.LocalAddr = udpLocal+}
{+ }+}
{+ conn, err := d.Dial("udp", address)+}
{+ if err != nil {+}
{+ return nil+}
{+ } // best-effort+}
{+ defer conn.Close()+}
{+ _, err = conn.Write([]byte{})+}
{+ if err != nil {+}
{+ return nil+}
{+ }+}
{+ case "tcp":+}
{+ // Короткий таймаут и игнор ошибок+}
{+ var tcpLocal *net.TCPAddr+}
{+ if la, ok := localAddr.(*net.TCPAddr); ok {+}
{+ tcpLocal = la+}
{+ }+}
{+ d := &net.Dialer{Timeout: 100 * time.Millisecond}+}
{+ if tcpLocal != nil {+}
{+ d.LocalAddr = tcpLocal+}
{+ }+}
{+ conn, err := d.Dial("tcp", address)+}
{+ if err != nil {+}
{+ return nil+}
{+ }+}
{+ defer conn.Close()+}
{+ _, _ = conn.Write([]byte{})+}
{+ }+}
{+ return nil+}
{+}+}
// showEasterEgg показывает забавный ASCII-арт
func (pk *PortKnocker) showEasterEgg() {
fmt.Println("\n🎯 🎯 🎯 EASTER EGG ACTIVATED! 🎯 🎯 🎯")
diff --git a/back/main.go b/back/main.go
index 845fa06..d06f9fe 100644
--- a/back/main.go
+++ b/back/main.go
@@ -7,7 +7,7 @@ import (
"port-knocker/cmd"
)
[-// Version и BuildTime устанавливаются при сборке через ldflags-]
var (
Version = "v1.0.10"
BuildTime = "unknown"
diff --git a/back/scripts/quick-release.sh b/back/scripts/quick-release.sh
index 8024937..0cb3133 100755
--- a/back/scripts/quick-release.sh
+++ b/back/scripts/quick-release.sh
@@ -112,6 +112,7 @@ git push origin main
# Сборка бинарников
log_info "Собираем бинарники для всех платформ..."
export VERSION_NUM="${VERSION#v}"
{+# shellcheck disable=SC2155+}
export BUILD_TIME=$(date -u '+%Y-%m-%d_%H:%M:%S')
# Функция сборки для платформы
@@ -155,6 +156,7 @@ log_info "Создаем Git тег..."
# Читаем release-notes.md и сохраняем содержимое в переменную NOTES
NOTES=$(cat docs/scripts/release-notes.md)
# Заменяем все переменные вида $VERSION в NOTES на их значения
{+# shellcheck disable=SC2001+}
NOTES=$(echo "$NOTES" | sed "s/\\\$VERSION/$VERSION/g")
git tag -a "$VERSION" -m "$NOTES"
diff --git a/knocker-serve b/knocker-serve
deleted file mode 100755
index c959979..0000000
Binary files a/knocker-serve and /dev/null differ
diff --git a/ui/src/app/app.routes.ts b/ui/src/app/app.routes.ts
index 69181c5..6599deb 100644
--- a/ui/src/app/app.routes.ts
+++ b/ui/src/app/app.routes.ts
@@ -1,9 +1,8 @@
import { Routes } from '@angular/router';
import { BasicKnockPageComponent } from './basic-knock/basic-knock-page.component';
[-import { FsaKnockPageComponent } from './fsa-knock/fsa-knock-page.component';-]
export const routes: Routes = [
{ path: '', component: BasicKnockPageComponent },
[- { path: 'fsa', component: FsaKnockPageComponent },-]
{ path: '**', redirectTo: '' }
];
diff --git a/ui/src/app/basic-knock/basic-knock-page.component.ts b/ui/src/app/basic-knock/basic-knock-page.component.ts
index f2be37d..cabf2c5 100644
--- a/ui/src/app/basic-knock/basic-knock-page.component.ts
+++ b/ui/src/app/basic-knock/basic-knock-page.component.ts
@@ -15,7 +15,7 @@ import { KnockPageComponent } from '../knock/knock-page.component';
template: `
diff --git a/ui/src/app/fsa-knock/fsa-knock-page.component.ts b/ui/src/app/fsa-knock/fsa-knock-page.component.ts
deleted file mode 100644
index 00c1c44..0000000
--- a/ui/src/app/fsa-knock/fsa-knock-page.component.ts
+++ /dev/null
@@ -1,132 +0,0 @@
[-import { Component } from '@angular/core';-]
[-import { CommonModule } from '@angular/common';-]
[-import { RouterModule } from '@angular/router';-]
[-import { CardModule } from 'primeng/card';-]
[-import { ButtonModule } from 'primeng/button';-]
[-import { DialogModule } from 'primeng/dialog';-]
[-import { KnockPageComponent } from '../knock/knock-page.component';-]
[-@Component({-]
[- selector: 'app-fsa-knock-page',-]
[- standalone: true,-]
[- imports: [-]
[- CommonModule, RouterModule, CardModule, ButtonModule, DialogModule, KnockPageComponent-]
[- ],-]
[- template: `-]
[- -]
[-
-]
[-
File System Access API не поддерживается -]
[-
Эта функциональность требует браузер с поддержкой File System Access API:
-]
[-
-]
[- Google Chrome 86+ -]
[- Microsoft Edge 86+ -]
[- Opera 72+ -]
[- -]
[-
Ваш браузер: {{ browserInfo }}
-]
[-
-]
[- -]
[-
-]
[- -]
[-
-]
[-
-]
[- -]
[- -]
[- -]
[-
-]
[- Эта версия поддерживает прямое редактирование файлов на диске.-]
[- Файлы будут автоматически перезаписываться после шифрования/дешифрования.-]
[-
-]
[-
-]
[-
-]
[- ✅ Доступные возможности: -]
[-
-]
[-
-]
[- Прямое открытие файлов с диска -]
[- Автоматическое сохранение изменений -]
[- Перезапись зашифрованных файлов "на месте" -]
[- Быстрая работа без диалогов загрузки/скачивания -]
[- -]
[-
-]
[-
-]
[- -]
[- `,-]
[- styles: [`-]
[- .container {-]
[- max-width: 1200px;-]
[- margin: 0 auto;-]
[- padding: 1rem;-]
[- }-]
[- -]
[- ul {-]
[- display: inline-block;-]
[- text-align: left;-]
[- }-]
[- -]
[- .info-link {-]
[- color: #3b82f6;-]
[- cursor: pointer;-]
[- text-decoration: none;-]
[- font-weight: 500;-]
[- transition: color 0.2s ease;-]
[- }-]
[- -]
[- .info-link:hover {-]
[- color: #1d4ed8;-]
[- text-decoration: underline;-]
[- }-]
[- -]
[- .bg-green-50 {-]
[- background-color: #f0fdf4;-]
[- }-]
[- -]
[- .dialog-content {-]
[- min-width: 450px;-]
[- }-]
[- `]-]
[-})-]
[-export class FsaKnockPageComponent {-]
[- isFSASupported = false;-]
[- browserInfo = '';-]
[- showInfoDialog = false;-]
[- constructor() {-]
[- this.checkFSASupport();-]
[- this.getBrowserInfo();-]
[- }-]
[- private checkFSASupport() {-]
[- const w = window as any;-]
[- this.isFSASupported = typeof w.showOpenFilePicker === 'function';-]
[- }-]
[- private getBrowserInfo() {-]
[- const ua = navigator.userAgent;-]
[- if (ua.includes('Chrome') && !ua.includes('Edg/')) {-]
[- this.browserInfo = 'Google Chrome';-]
[- } else if (ua.includes('Edg/')) {-]
[- this.browserInfo = 'Microsoft Edge';-]
[- } else if (ua.includes('Opera') || ua.includes('OPR/')) {-]
[- this.browserInfo = 'Opera';-]
[- } else if (ua.includes('Firefox')) {-]
[- this.browserInfo = 'Mozilla Firefox';-]
[- } else if (ua.includes('Safari') && !ua.includes('Chrome')) {-]
[- this.browserInfo = 'Safari';-]
[- } else {-]
[- this.browserInfo = 'Неизвестный браузер';-]
[- }-]
[- }-]
[-}-]
diff --git a/ui/src/app/knock/knock-page.component.html b/ui/src/app/knock/knock-page.component.html
index 07ca8f5..c36bfda 100644
--- a/ui/src/app/knock/knock-page.component.html
+++ b/ui/src/app/knock/knock-page.component.html
@@ -1,76 +1,19 @@
-]
[- -]
[- -]
[-
Port Knocker -]
[- -]
[-
-]
[- -]
[- -]
[-
-]
[-
-]
[- -]{+header="Port Knocker (Minimal UI)">+}
[--]
[--]
[- -]
[-
-]
[-
✅ Успешно выполнено -]
[-
{{ result }} -]
[-
-]
[-
-]
[-
❌ Ошибка -]
[-
{{-]
[- error-]
[- }} -]
[-
-]
[-
-]
[- Время выполнения: {{ elapsedMs / 1000 | number : "1.1-1" }}s-]
[- -]
[- Завершено: {{ lastRunTime | date : "short" }}-]
[-
-]
[-
-]
[- -]
[- -]
[- -]
[--]
[--]
[--]
[- -]
[-
-]
[- Эта версия работает в любом браузере, но файлы загружаются/скачиваются-]
[- через стандартные диалоги браузера.-]
[-
-]
[-
-]
[-
-]
[- 💡 Доступна расширенная версия! -]
[-
-]
[-
-]
[- Ваш браузер поддерживает прямое редактирование файлов на диске.-]
[-
-]
[-
-]
[- -]
[-
-]
[--]
diff --git a/ui/src/app/knock/knock-page.component.ts b/ui/src/app/knock/knock-page.component.ts
index bd7fd1d..37fb436 100644
--- a/ui/src/app/knock/knock-page.component.ts
+++ b/ui/src/app/knock/knock-page.component.ts
@@ -1,384 +1,81 @@
import { Component, [-inject, Input-]{+inject+} } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { FormBuilder, ReactiveFormsModule, [-Validators, FormsModule-]{+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-]
],
templateUrl: './knock-page.component.html',
styleUrls: [-['./knock-page.component.scss']-]{+['./knock-page.component.scss'],+}
})
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'],-]{+['tcp:127.0.0.1:22', Validators.required],+}
delay: [-['1s'],-]
[- verbose: [true],-]{+['1s', Validators.required],+}
waitConnection: [false],
[- gateway: [''],-]
[- configYAML: [''],-]
[- serverFilePath: ['']-]
});
[- 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,-]{+verbose: true,+}
};
[-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({-]{+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.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.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 +98,4 @@ 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 между словами-]
[- }-]
}