init
16
ui/.editorconfig
Normal file
@@ -0,0 +1,16 @@
|
||||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.ts]
|
||||
quote_type = single
|
||||
|
||||
[*.md]
|
||||
max_line_length = 100
|
||||
trim_trailing_whitespace = false
|
42
ui/.gitignore
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||
|
||||
# Compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
|
||||
# Node
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# IDEs and editors
|
||||
.idea/
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
.history/*
|
||||
|
||||
# Miscellaneous
|
||||
/.angular/cache
|
||||
.sass-cache/
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
testem.log
|
||||
/typings
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
69
ui/README.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# ProjectApp
|
||||
|
||||
Современное веб-приложение для обеспечения GUI в браузере,
|
||||
построенное на Angular 17 с использованием PrimeNG.
|
||||
|
||||
## 🚀 Быстрый старт
|
||||
|
||||
### Установка зависимостей
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Запуск в режиме разработки
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
Приложение будет доступно по адресу `http://localhost:4200/`
|
||||
|
||||
### Сборка для продакшена
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Артефакты сборки будут сохранены в папке `dist/`
|
||||
|
||||
## 🏗️ Архитектура
|
||||
|
||||
- **Frontend**: Angular 17 с PrimeNG 17
|
||||
- **Backend**: Go с Gin (отдельный проект)
|
||||
- **API**: REST API для получения данных о погоде
|
||||
- **Стили**: SCSS с Glassmorphism эффектами
|
||||
|
||||
## 🔧 Разработка
|
||||
|
||||
### Генерация компонентов
|
||||
|
||||
```bash
|
||||
ng generate component component-name
|
||||
```
|
||||
|
||||
### Тестирование
|
||||
|
||||
```bash
|
||||
# Unit тесты
|
||||
ng test
|
||||
|
||||
# E2E тесты
|
||||
ng e2e
|
||||
```
|
||||
|
||||
### Линтинг
|
||||
|
||||
```bash
|
||||
# Проверка стиля кода
|
||||
ng lint
|
||||
|
||||
## 📦 Сборка для встраивания
|
||||
|
||||
Для встраивания в Go приложение:
|
||||
|
||||
```bash
|
||||
npm run build:embed [/path/to/front] # /home/user/projects/golang/go-project/project-front
|
||||
```
|
||||
|
||||
Файлы будут собраны в папку `/path/to/front`
|
101
ui/angular.json
Normal file
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"project-front": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
}
|
||||
},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:application",
|
||||
"options": {
|
||||
"outputPath": "dist/project-front",
|
||||
"index": "src/index.html",
|
||||
"browser": "src/main.ts",
|
||||
"polyfills": ["zone.js"],
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/assets",
|
||||
"src/manifest.webmanifest"
|
||||
],
|
||||
"styles": ["src/styles.scss"],
|
||||
"scripts": []
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "1.5mb",
|
||||
"maximumError": "2mb"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "128kb",
|
||||
"maximumError": "256kb"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all",
|
||||
"serviceWorker": "ngsw-config.json",
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.prod.ts"
|
||||
}
|
||||
]
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "project-front:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "project-front:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n",
|
||||
"options": {
|
||||
"buildTarget": "project-front:build"
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"options": {
|
||||
"polyfills": ["zone.js", "zone.js/testing"],
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/assets",
|
||||
"src/manifest.webmanifest"
|
||||
],
|
||||
"styles": ["src/styles.scss"],
|
||||
"scripts": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
20
ui/build-for-embeding.sh
Executable file
@@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [ -z "$1" ]; then
|
||||
echo "Ошибка: Пожалуйста, укажите директорию назначения."
|
||||
exit 1
|
||||
fi
|
||||
DESTINATION_DIR=$1
|
||||
|
||||
echo "Building Angular app for embedding..."
|
||||
# ng build --configuration production --output-path ../../golang/gin-restapi/weather-front
|
||||
rm -rf "$DESTINATION_DIR"
|
||||
npx ng build --configuration production
|
||||
|
||||
mkdir -p "$DESTINATION_DIR"
|
||||
|
||||
cp -r /home/su/projects/angular/project-front/dist/project-front/browser/* \
|
||||
"$DESTINATION_DIR"
|
||||
|
||||
echo "Build completed successfully!"
|
||||
echo "Frontend files are ready for embedding in Go binary"
|
30
ui/ngsw-config.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
|
||||
"index": "/index.html",
|
||||
"assetGroups": [
|
||||
{
|
||||
"name": "app",
|
||||
"installMode": "prefetch",
|
||||
"resources": {
|
||||
"files": [
|
||||
"/favicon.ico",
|
||||
"/index.html",
|
||||
"/manifest.webmanifest",
|
||||
"/*.css",
|
||||
"/*.js"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "assets",
|
||||
"installMode": "lazy",
|
||||
"updateMode": "prefetch",
|
||||
"resources": {
|
||||
"files": [
|
||||
"/assets/**",
|
||||
"/media/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
13460
ui/package-lock.json
generated
Normal file
48
ui/package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "front-project",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"build:embed": "ng build --configuration production --output-path ",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test",
|
||||
"server": "http-server -p 8880 -c-1 dist/front-project/browser"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^17.3.0",
|
||||
"@angular/common": "^17.3.0",
|
||||
"@angular/compiler": "^17.3.0",
|
||||
"@angular/core": "^17.3.0",
|
||||
"@angular/forms": "^17.3.0",
|
||||
"@angular/platform-browser": "^17.3.0",
|
||||
"@angular/platform-browser-dynamic": "^17.3.0",
|
||||
"@angular/router": "^17.3.0",
|
||||
"@angular/service-worker": "^17.3.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"primeflex": "^3.3.1",
|
||||
"primeicons": "^7.0.0",
|
||||
"primeng": "^17.18.15",
|
||||
"roboto-fontface": "^0.10.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"zone.js": "~0.14.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^17.3.8",
|
||||
"@angular/cli": "^17.3.8",
|
||||
"@angular/compiler-cli": "^17.3.0",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"http-server": "^14.1.1",
|
||||
"jasmine-core": "~5.1.0",
|
||||
"karma": "~6.4.0",
|
||||
"karma-chrome-launcher": "~3.2.0",
|
||||
"karma-coverage": "~2.2.0",
|
||||
"karma-jasmine": "~5.1.0",
|
||||
"karma-jasmine-html-reporter": "~2.1.0",
|
||||
"typescript": "~5.4.2"
|
||||
}
|
||||
}
|
1
ui/src/app/app.component.html
Normal file
@@ -0,0 +1 @@
|
||||
<router-outlet></router-outlet>
|
26
ui/src/app/app.component.scss
Normal file
@@ -0,0 +1,26 @@
|
||||
ul {
|
||||
list-style-type: none; /* Remove default list styling */
|
||||
padding: 0; /* Remove default padding */
|
||||
}
|
||||
|
||||
li {
|
||||
cursor: pointer; /* Change cursor to pointer on hover */
|
||||
padding: 10px; /* Add some padding for better click area */
|
||||
transition: background-color 0.3s; /* Smooth transition for background color */
|
||||
}
|
||||
|
||||
li:hover {
|
||||
color: #9a5d5d; /* Change background color on hover */
|
||||
font-weight: bold;
|
||||
font-size: larger;
|
||||
}
|
||||
|
||||
.center-container {
|
||||
margin-top: 30px;
|
||||
display: flex; /* Use Flexbox */
|
||||
flex-direction: column; /* Stack children vertically */
|
||||
align-items: center; /* Center horizontally */
|
||||
// justify-content: center; /* Center vertically */
|
||||
// height: 100vh; /* Full viewport height */
|
||||
text-align: center; /* Center text */
|
||||
}
|
12
ui/src/app/app.component.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterOutlet],
|
||||
templateUrl: './app.component.html',
|
||||
styleUrl: './app.component.scss',
|
||||
})
|
||||
export class AppComponent {}
|
18
ui/src/app/app.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ApplicationConfig, isDevMode } from '@angular/core';
|
||||
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { provideServiceWorker } from '@angular/service-worker';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { routes } from './app.routes';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideAnimationsAsync(),
|
||||
provideHttpClient(),
|
||||
provideRouter(routes),
|
||||
provideServiceWorker('ngsw-worker.js', {
|
||||
enabled: !isDevMode(),
|
||||
registrationStrategy: 'registerWhenStable:30000'
|
||||
})
|
||||
],
|
||||
};
|
9
ui/src/app/app.routes.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
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: '' }
|
||||
];
|
92
ui/src/app/basic-knock/basic-knock-page.component.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
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-basic-knock-page',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule, RouterModule, CardModule, ButtonModule, DialogModule, KnockPageComponent
|
||||
],
|
||||
template: `
|
||||
<div class="container">
|
||||
<!-- Встраиваем основной компонент в базовом режиме -->
|
||||
<app-knock-page [enableFSA]="false" [canUseFSA]="canUseFSA"></app-knock-page>
|
||||
</div>
|
||||
|
||||
<!-- Информационное модальное окно -->
|
||||
<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>
|
||||
`,
|
||||
styles: [`
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.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-blue-50 {
|
||||
background-color: #eff6ff;
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
min-width: 400px;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class BasicKnockPageComponent {
|
||||
canUseFSA = false;
|
||||
showInfoDialog = false;
|
||||
|
||||
constructor() {
|
||||
this.checkFSASupport();
|
||||
}
|
||||
|
||||
private checkFSASupport() {
|
||||
const w = window as any;
|
||||
this.canUseFSA = typeof w.showOpenFilePicker === 'function';
|
||||
}
|
||||
}
|
132
ui/src/app/fsa-knock/fsa-knock-page.component.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
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 = 'Неизвестный браузер';
|
||||
}
|
||||
}
|
||||
}
|
317
ui/src/app/knock/knock-page.component.html
Normal file
@@ -0,0 +1,317 @@
|
||||
<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>
|
||||
<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'">
|
||||
<label>Targets</label>
|
||||
<input
|
||||
pInputText
|
||||
type="text"
|
||||
formControlName="targets"
|
||||
placeholder="tcp:host:port;udp:host:port"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-4" *ngIf="form.value.mode === 'inline'">
|
||||
<label>Delay</label>
|
||||
<input
|
||||
pInputText
|
||||
type="text"
|
||||
formControlName="delay"
|
||||
placeholder="1s"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-6 md:col-4 flex align-items-center gap-2">
|
||||
<p-checkbox formControlName="verbose" [binary]="true"></p-checkbox>
|
||||
<label class="checkbox-label">Verbose</label>
|
||||
</div>
|
||||
|
||||
<div class="col-6 md:col-4 flex align-items-center gap-2">
|
||||
<p-checkbox
|
||||
formControlName="waitConnection"
|
||||
[binary]="true"
|
||||
></p-checkbox>
|
||||
<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
|
||||
type="submit"
|
||||
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"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p-divider></p-divider>
|
||||
|
||||
<div class="grid">
|
||||
<div class="col-12">
|
||||
<p-progressBar
|
||||
[value]="executing ? 100 : 0"
|
||||
[mode]="executing ? 'indeterminate' : 'determinate'"
|
||||
></p-progressBar>
|
||||
<div class="mt-2 text-600" *ngIf="executing">
|
||||
Elapsed: {{ elapsedMs / 1000 | number : "1.1-1" }}s
|
||||
</div>
|
||||
<div class="mt-2 text-600" *ngIf="!executing && elapsedMs > 0">
|
||||
Last run: {{ elapsedMs / 1000 | number : "1.1-1" }}s
|
||||
<span *ngIf="lastRunTime" class="ml-2 text-500">
|
||||
({{ lastRunTime | date : "short" }})
|
||||
</span>
|
||||
</div>
|
||||
</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>
|
132
ui/src/app/knock/knock-page.component.scss
Normal file
@@ -0,0 +1,132 @@
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 24px auto;
|
||||
padding: 16px;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin: 8px 0;
|
||||
}
|
||||
label {
|
||||
width: 180px;
|
||||
}
|
||||
input[type='text'], input[type='password'], select, textarea {
|
||||
flex: 1;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.result, .error {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.error pre {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
min-width: 400px;
|
||||
}
|
||||
|
||||
.bg-blue-50 {
|
||||
background-color: #eff6ff;
|
||||
}
|
||||
|
||||
.result-dialog {
|
||||
.p-dialog {
|
||||
max-width: 90vw !important;
|
||||
width: 600px !important;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
width: 95vw !important;
|
||||
max-width: 95vw !important;
|
||||
}
|
||||
}
|
||||
|
||||
.p-dialog-content {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
font-size: 0.8rem;
|
||||
max-height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
min-width: 300px;
|
||||
max-width: 100%;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Анимированный заголовок
|
||||
.animated-title {
|
||||
margin-left: 1.5rem;
|
||||
background: linear-gradient(45deg, #667eea 0%, #764ba2 100%);
|
||||
background-size: 200% 200%;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
animation: gradientShift 3s ease-in-out infinite;
|
||||
font-weight: bold;
|
||||
font-size: 1.8rem;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&.animating {
|
||||
animation: gradientShift 1s ease-in-out infinite, glow 0.5s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
min-width: 1ch;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gradientShift {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glow {
|
||||
from {
|
||||
text-shadow: 0 0 5px rgba(102, 126, 234, 0.5), 0 0 10px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
to {
|
||||
text-shadow: 0 0 10px rgba(102, 126, 234, 0.8), 0 0 20px rgba(102, 126, 234, 0.5);
|
||||
}
|
||||
}
|
||||
|
780
ui/src/app/knock/knock-page.component.ts
Normal file
@@ -0,0 +1,780 @@
|
||||
import { Component, inject, Input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators, FormsModule } 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']
|
||||
})
|
||||
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;
|
||||
private timerId: any = null;
|
||||
private startTs = 0;
|
||||
elapsedMs = 0;
|
||||
lastRunTime: Date | null = null;
|
||||
wrongPass = false;
|
||||
selectedFileName: string | null = null;
|
||||
private fileHandle: any = null; // FileSystemFileHandle
|
||||
private isSyncing = false;
|
||||
result: string | null = null;
|
||||
error: string | null = null;
|
||||
|
||||
form = this.fb.group({
|
||||
password: ['', Validators.required],
|
||||
mode: ['inline', Validators.required],
|
||||
targets: ['tcp:127.0.0.1:22'],
|
||||
delay: ['1s'],
|
||||
verbose: [true],
|
||||
waitConnection: [false],
|
||||
gateway: [''],
|
||||
configYAML: [''],
|
||||
serverFilePath: ['']
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
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({
|
||||
next: () => {
|
||||
this.executing = false;
|
||||
this.stopTimer();
|
||||
this.lastRunTime = new Date();
|
||||
this.result = `Done in ${(this.elapsedMs/1000).toFixed(2)}s`;
|
||||
this.showResultDialog = true;
|
||||
},
|
||||
error: (e: HttpErrorResponse) => {
|
||||
this.executing = false;
|
||||
this.stopTimer();
|
||||
if (e.status === 401) {
|
||||
this.wrongPass = true;
|
||||
}
|
||||
this.error = (e.error?.error) || e.message;
|
||||
this.showResultDialog = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
encrypt() {
|
||||
this.error = null;
|
||||
this.result = null;
|
||||
const v = this.form.value;
|
||||
if (this.isInlineMode() || this.isYamlEncrypted() || !v.password || this.wrongPass) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем есть ли path в YAML самом
|
||||
const pathFromYaml = this.getPathFromYaml(v.configYAML || '');
|
||||
const serverFilePath = (this.form.value.serverFilePath || '').trim();
|
||||
|
||||
let url: string;
|
||||
let body: any;
|
||||
|
||||
if (pathFromYaml) {
|
||||
// Если path в YAML - используем /encrypt, сервер сам найдет path в YAML
|
||||
url = '/api/v1/knock-actions/encrypt';
|
||||
body = { yaml: v.configYAML };
|
||||
} else if (serverFilePath) {
|
||||
// Если path только в serverFilePath - используем /encrypt-file
|
||||
url = '/api/v1/knock-actions/encrypt-file';
|
||||
body = { path: serverFilePath };
|
||||
} else {
|
||||
// Нет пути - обычное шифрование содержимого
|
||||
url = '/api/v1/knock-actions/encrypt';
|
||||
body = { yaml: v.configYAML };
|
||||
}
|
||||
|
||||
this.http.post(url, body, {
|
||||
headers: this.authHeader(v.password || '')
|
||||
}).subscribe({
|
||||
next: async (res: any) => {
|
||||
const encrypted: string = res.encrypted || '';
|
||||
|
||||
// Всегда обновляем YAML поле зашифрованным содержимым
|
||||
this.form.patchValue({ configYAML: encrypted });
|
||||
|
||||
if (pathFromYaml) {
|
||||
this.result = `Encrypted (YAML path: ${pathFromYaml})`;
|
||||
// НЕ сохраняем файл клиентом - сервер уже записал по path из YAML
|
||||
} else if (serverFilePath) {
|
||||
this.result = `Encrypted (server path: ${serverFilePath})`;
|
||||
// НЕ сохраняем файл клиентом - сервер записал по serverFilePath
|
||||
} else {
|
||||
this.result = 'Encrypted';
|
||||
// Только сохраняем в файл если НЕТ серверного пути
|
||||
await this.saveBackToFileIfPossible(encrypted, this.selectedFileName);
|
||||
}
|
||||
},
|
||||
error: (e: HttpErrorResponse) => this.error = (e.error && e.error.error) || e.message
|
||||
});
|
||||
}
|
||||
|
||||
decrypt() {
|
||||
this.error = null;
|
||||
this.result = null;
|
||||
const v = this.form.value;
|
||||
if (this.isInlineMode() || !this.isYamlEncrypted() || !v.password || this.wrongPass) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Для зашифрованного YAML поле serverFilePath недоступно - используем только decrypt
|
||||
const url = '/api/v1/knock-actions/decrypt';
|
||||
const body = { encrypted: v.configYAML as string };
|
||||
|
||||
this.http.post(url, body, {
|
||||
headers: this.authHeader(v.password || '')
|
||||
}).subscribe({
|
||||
next: async (res: any) => {
|
||||
const plain: string = res.yaml || '';
|
||||
this.form.patchValue({ configYAML: plain });
|
||||
this.result = 'Decrypted';
|
||||
|
||||
// Извлекаем path из расшифрованного YAML и обновляем serverFilePath
|
||||
const pathFromDecrypted = this.getPathFromYaml(plain);
|
||||
if (pathFromDecrypted) {
|
||||
this.isSyncing = true;
|
||||
this.form.patchValue({ serverFilePath: pathFromDecrypted });
|
||||
this.isSyncing = false;
|
||||
this.result += ` (found path: ${pathFromDecrypted})`;
|
||||
}
|
||||
|
||||
// НЕ делаем download - сервер уже обработал файл согласно path в YAML
|
||||
},
|
||||
error: (e: HttpErrorResponse) => this.error = (e.error && e.error.error) || e.message
|
||||
});
|
||||
}
|
||||
|
||||
onFileSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (!input.files || input.files.length === 0) return;
|
||||
const file = input.files[0];
|
||||
this.selectedFileName = file.name;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const text = String(reader.result || '');
|
||||
this.form.patchValue({ configYAML: text, mode: 'yaml' });
|
||||
// Sync path from YAML into serverFilePath
|
||||
const p = this.extractPathFromYaml(text);
|
||||
if (p) {
|
||||
this.isSyncing = true;
|
||||
this.form.patchValue({ serverFilePath: p });
|
||||
this.isSyncing = false;
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
this.fileHandle = null; // обычная загрузка не даёт handle на запись
|
||||
}
|
||||
|
||||
downloadYaml() {
|
||||
const yaml = this.form.value.configYAML || '';
|
||||
this.triggerDownload('config.yaml', yaml);
|
||||
}
|
||||
|
||||
downloadResult() {
|
||||
const content = this.result || this.form.value.configYAML || '';
|
||||
const name = (content || '').startsWith('ENCRYPTED:') ? 'config.encrypted' : 'config.yaml';
|
||||
this.triggerDownload(name, content);
|
||||
}
|
||||
|
||||
onFileUpload(event: any) {
|
||||
const files: File[] = event?.files || event?.currentFiles || [];
|
||||
if (!files.length) return;
|
||||
const file = files[0];
|
||||
this.selectedFileName = file.name;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const text = String(reader.result || '');
|
||||
this.form.patchValue({ configYAML: text, mode: 'yaml' });
|
||||
const p = this.extractPathFromYaml(text);
|
||||
if (!p) {
|
||||
return;
|
||||
}
|
||||
this.isSyncing = true;
|
||||
this.form.patchValue({ serverFilePath: p });
|
||||
this.isSyncing = false;
|
||||
};
|
||||
reader.readAsText(file);
|
||||
this.fileHandle = null;
|
||||
}
|
||||
|
||||
async openFileWithWriteAccess() {
|
||||
try {
|
||||
const w: any = window as any;
|
||||
if (!w || typeof w.showOpenFilePicker !== 'function') {
|
||||
this.error = 'File System Access API is not supported by this browser.';
|
||||
return;
|
||||
}
|
||||
const [handle] = await w.showOpenFilePicker({
|
||||
types: [{ description: 'YAML/Encrypted', accept: { 'text/plain': ['.yaml', '.yml', '.encrypted', '.txt'] } }]
|
||||
});
|
||||
this.fileHandle = handle;
|
||||
const file = await handle.getFile();
|
||||
this.selectedFileName = file.name;
|
||||
const text = await file.text();
|
||||
this.form.patchValue({ configYAML: text, mode: 'yaml' });
|
||||
const p = this.extractPathFromYaml(text);
|
||||
if (p) {
|
||||
this.isSyncing = true;
|
||||
this.form.patchValue({ serverFilePath: p });
|
||||
this.isSyncing = false;
|
||||
}
|
||||
this.result = `Opened: ${file.name}`;
|
||||
this.error = null;
|
||||
} catch (e: any) {
|
||||
// user cancelled or error
|
||||
}
|
||||
}
|
||||
|
||||
// Helpers for UI state
|
||||
isInlineMode(): boolean {
|
||||
return (this.form.value.mode === 'inline');
|
||||
}
|
||||
isYamlEncrypted(): boolean {
|
||||
const s = (this.form.value.configYAML || '').toString().trim();
|
||||
return s.startsWith('ENCRYPTED:');
|
||||
}
|
||||
|
||||
private triggerDownload(filename: string, text: string) {
|
||||
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
private async saveBackToFileIfPossible(content: string, filename: string | null) {
|
||||
try {
|
||||
const w: any = window as any;
|
||||
if (this.fileHandle && typeof this.fileHandle.createWritable === 'function') {
|
||||
const writable = await this.fileHandle.createWritable();
|
||||
await writable.write(content);
|
||||
await writable.close();
|
||||
return;
|
||||
}
|
||||
if (w && typeof w.showSaveFilePicker === 'function') {
|
||||
const handle = await w.showSaveFilePicker({
|
||||
suggestedName: filename || 'config.yaml',
|
||||
types: [{ description: 'YAML/Encrypted', accept: { 'text/plain': ['.yaml', '.yml', '.encrypted', '.txt'] } }]
|
||||
});
|
||||
const writable = await handle.createWritable();
|
||||
await writable.write(content);
|
||||
await writable.close();
|
||||
return;
|
||||
} else if (filename) {
|
||||
this.triggerDownload(filename, content);
|
||||
} else {
|
||||
this.triggerDownload('config.yaml', content);
|
||||
}
|
||||
} catch {
|
||||
if (filename) {
|
||||
this.triggerDownload(filename, content);
|
||||
} else {
|
||||
this.triggerDownload('config.yaml', content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private startTimer() {
|
||||
this.elapsedMs = 0;
|
||||
this.startTs = Date.now();
|
||||
this.clearTimer();
|
||||
this.timerId = setInterval(() => {
|
||||
this.elapsedMs = Date.now() - this.startTs;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
private stopTimer() {
|
||||
if (this.startTs > 0) {
|
||||
this.elapsedMs = Date.now() - this.startTs;
|
||||
}
|
||||
this.clearTimer();
|
||||
}
|
||||
|
||||
private clearTimer() {
|
||||
if (this.timerId) {
|
||||
clearInterval(this.timerId);
|
||||
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 между словами
|
||||
}
|
||||
}
|
||||
|
||||
|
0
ui/src/assets/.gitkeep
Normal file
BIN
ui/src/assets/icons/icon-128x128.png
Normal file
After Width: | Height: | Size: 2.8 KiB |
BIN
ui/src/assets/icons/icon-144x144.png
Normal file
After Width: | Height: | Size: 3.0 KiB |
BIN
ui/src/assets/icons/icon-152x152.png
Normal file
After Width: | Height: | Size: 3.2 KiB |
BIN
ui/src/assets/icons/icon-192x192.png
Normal file
After Width: | Height: | Size: 4.2 KiB |
BIN
ui/src/assets/icons/icon-384x384.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
ui/src/assets/icons/icon-512x512.png
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
ui/src/assets/icons/icon-72x72.png
Normal file
After Width: | Height: | Size: 1.9 KiB |
BIN
ui/src/assets/icons/icon-96x96.png
Normal file
After Width: | Height: | Size: 2.3 KiB |
16
ui/src/environments/environment.prod.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
const logFunction = (...messages: any[]) => {};
|
||||
const errorLogFunction = (...messages: any[]) => {};
|
||||
|
||||
export const environment = {
|
||||
production: true,
|
||||
apiBaseUrl: '/api/v1',
|
||||
adminApiUrl: '/api/v1/project',
|
||||
log: logFunction,
|
||||
errLog: errorLogFunction,
|
||||
debugAny: (
|
||||
something: any,
|
||||
transformer: (...args: any[]) => any = (...args: any[]): any => {
|
||||
return args[0];
|
||||
}
|
||||
) => transformer(something),
|
||||
};
|
26
ui/src/environments/environment.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
|
||||
|
||||
const logFunction = (...messages: any[]) => {
|
||||
messages.forEach((msg) => console.log(msg));
|
||||
};
|
||||
|
||||
const errorLogFunction = (...messages: any[]) => {
|
||||
messages.forEach((msg) => console.error(msg));
|
||||
};
|
||||
|
||||
const debugAny = (
|
||||
something: any,
|
||||
transformer: (...args: any[]) => any = (...args: any[]): any => {
|
||||
return args[0];
|
||||
}
|
||||
) => transformer(something);
|
||||
|
||||
|
||||
export const environment = {
|
||||
production: false,
|
||||
apiBaseUrl: 'http://localhost:8080/api/v1',
|
||||
adminApiUrl: 'http://localhost:8080/api/v1/project',
|
||||
log: logFunction,
|
||||
errLog: errorLogFunction,
|
||||
debugAny
|
||||
};
|
BIN
ui/src/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
16
ui/src/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Port-Knocker UI</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<meta name="theme-color" content="#1976d2">
|
||||
</head>
|
||||
<body class="mat-typography">
|
||||
<app-root></app-root>
|
||||
<noscript>Please enable JavaScript to continue using this application.</noscript>
|
||||
</body>
|
||||
</html>
|
6
ui/src/main.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { appConfig } from './app/app.config';
|
||||
import { AppComponent } from './app/app.component';
|
||||
|
||||
bootstrapApplication(AppComponent, appConfig)
|
||||
.catch((err) => console.error(err));
|
59
ui/src/manifest.webmanifest
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "weather-app",
|
||||
"short_name": "weather-app",
|
||||
"theme_color": "#1976d2",
|
||||
"background_color": "#fafafa",
|
||||
"display": "standalone",
|
||||
"scope": "./",
|
||||
"start_url": "./",
|
||||
"icons": [
|
||||
{
|
||||
"src": "assets/icons/icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/icon-128x128.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/icon-152x152.png",
|
||||
"sizes": "152x152",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/icon-384x384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
}
|
||||
]
|
||||
}
|
46
ui/src/styles.scss
Normal file
@@ -0,0 +1,46 @@
|
||||
/* You can add global styles to this file, and also import other style files */
|
||||
|
||||
/* PrimeNG */
|
||||
@import "primeng/resources/themes/lara-light-blue/theme.css";
|
||||
// @import "primeng/resources/themes/saga-blue/theme.css";
|
||||
// @import "primeng/resources/themes/nova/theme.css";
|
||||
@import "primeng/resources/primeng.css";
|
||||
@import "primeflex/primeflex.css";
|
||||
|
||||
/* PrimeIcons */
|
||||
@import "primeicons/primeicons.css";
|
||||
|
||||
/* Roboto local (roboto-fontface) */
|
||||
@import "roboto-fontface/css/roboto/roboto-fontface.css";
|
||||
|
||||
/* Override PrimeNG font to use Roboto instead of Inter */
|
||||
:root {
|
||||
--font-family: "Roboto", -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif !important;
|
||||
font-family: "Roboto", -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif !important;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Roboto", -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* Стили для label элементов */
|
||||
label {
|
||||
display: block !important;
|
||||
margin-bottom: 0.5rem !important;
|
||||
padding-left: 0.5rem !important;
|
||||
font-weight: 500 !important;
|
||||
color: var(--text-color) !important;
|
||||
}
|
||||
|
||||
/* Исключение для label элементов рядом с checkbox */
|
||||
label.checkbox-label {
|
||||
display: inline !important;
|
||||
margin-bottom: 0 !important;
|
||||
padding-left: 0 !important;
|
||||
}
|
14
ui/tsconfig.app.json
Normal file
@@ -0,0 +1,14 @@
|
||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/app",
|
||||
"types": []
|
||||
},
|
||||
"files": [
|
||||
"src/main.ts"
|
||||
],
|
||||
"include": [
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
32
ui/tsconfig.json
Normal file
@@ -0,0 +1,32 @@
|
||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/out-tsc",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"sourceMap": true,
|
||||
"declaration": false,
|
||||
"experimentalDecorators": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"useDefineForClassFields": false,
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"dom"
|
||||
]
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
}
|
||||
}
|
14
ui/tsconfig.spec.json
Normal file
@@ -0,0 +1,14 @@
|
||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/spec",
|
||||
"types": [
|
||||
"jasmine"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|