1589 lines
60 KiB
Plaintext
1589 lines
60 KiB
Plaintext
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: `
|
||
<div class="container">
|
||
<!-- Встраиваем основной компонент в базовом режиме -->
|
||
[-<app-knock-page [enableFSA]="false" [canUseFSA]="canUseFSA"></app-knock-page>-]{+<app-knock-page></app-knock-page>+}
|
||
</div>
|
||
|
||
<!-- Информационное модальное окно -->
|
||
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: `-]
|
||
[- <div class="container">-]
|
||
[- <div *ngIf="!isFSASupported" class="text-center">-]
|
||
[- <h3>File System Access API не поддерживается</h3>-]
|
||
[- <p>Эта функциональность требует браузер с поддержкой File System Access API:</p>-]
|
||
[- <ul class="text-left mt-3">-]
|
||
[- <li>Google Chrome 86+</li>-]
|
||
[- <li>Microsoft Edge 86+</li>-]
|
||
[- <li>Opera 72+</li>-]
|
||
[- </ul>-]
|
||
[- <p class="mt-3">Ваш браузер: <strong>{{ browserInfo }}</strong></p>-]
|
||
[- <button pButton -]
|
||
[- type="button" -]
|
||
[- label="Перейти к основной версии" -]
|
||
[- class="p-button-outlined mt-3"-]
|
||
[- routerLink="/">-]
|
||
[- </button>-]
|
||
[- </div>-]
|
||
[- -]
|
||
[- <div *ngIf="isFSASupported">-]
|
||
[- <!-- Встраиваем основной компонент с поддержкой FSA -->-]
|
||
[- <app-knock-page [enableFSA]="true" [canUseFSA]="true"></app-knock-page>-]
|
||
[- </div>-]
|
||
[- </div>-]
|
||
|
||
[- <!-- Информационное модальное окно -->-]
|
||
[- <p-dialog header="🚀 Расширенная версия с File System Access" -]
|
||
[- [(visible)]="showInfoDialog" -]
|
||
[- [modal]="true" -]
|
||
[- [closable]="true"-]
|
||
[- [draggable]="false"-]
|
||
[- [resizable]="false"-]
|
||
[- styleClass="info-dialog">-]
|
||
[- <div class="dialog-content">-]
|
||
[- <p class="mb-3">-]
|
||
[- Эта версия поддерживает прямое редактирование файлов на диске.-]
|
||
[- Файлы будут автоматически перезаписываться после шифрования/дешифрования.-]
|
||
[- </p>-]
|
||
[- <div class="p-3 bg-green-50 border-round">-]
|
||
[- <p class="text-sm mb-2">-]
|
||
[- ✅ <strong>Доступные возможности:</strong>-]
|
||
[- </p>-]
|
||
[- <ul class="text-sm mb-0">-]
|
||
[- <li>Прямое открытие файлов с диска</li>-]
|
||
[- <li>Автоматическое сохранение изменений</li>-]
|
||
[- <li>Перезапись зашифрованных файлов "на месте"</li>-]
|
||
[- <li>Быстрая работа без диалогов загрузки/скачивания</li>-]
|
||
[- </ul>-]
|
||
[- </div>-]
|
||
[- </div>-]
|
||
[- </p-dialog>-]
|
||
[- `,-]
|
||
[- 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 @@
|
||
<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>-]{+header="Port Knocker (Minimal 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'">-]{+class="col-12">+}
|
||
<label>Targets</label>
|
||
<input
|
||
pInputText
|
||
type="text"
|
||
formControlName="targets"
|
||
[-placeholder="tcp:host:port;udp:host:port"-]{+placeholder="tcp:host:port;udp:host:port;...;tcp:host:port"+}
|
||
class="w-full"
|
||
/>
|
||
</div>
|
||
|
||
<div class="col-12 [-md:col-4" *ngIf="form.value.mode === 'inline'">-]{+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-]{+class="col-12 md:col-6+} flex align-items-center gap-2">
|
||
<p-checkbox formControlName="waitConnection" [-[binary]="true"-]
|
||
[- ></p-checkbox>-]{+[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"-]{+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"-]{+'1.1-1'+} }}s
|
||
</div>
|
||
<div class="mt-2 text-600" *ngIf="!executing && [-elapsedMs > 0">-]
|
||
[- Last run:-]{+result">+}
|
||
{{ [-elapsedMs / 1000 | number : "1.1-1" }}s-]
|
||
[- <span *ngIf="lastRunTime" class="ml-2 text-500">-]
|
||
[- ({{ lastRunTime | date : "short" }})-]
|
||
[- </span>-]{+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>-]
|
||
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 между словами-]
|
||
[- }-]
|
||
}
|
||
|
||
|