This commit is contained in:
66 changed files with 19815 additions and 0 deletions

16
ui/.editorconfig Normal file
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

48
ui/package.json Normal file
View 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"
}
}

View File

@@ -0,0 +1 @@
<router-outlet></router-outlet>

View 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 */
}

View 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
View 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
View 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: '' }
];

View 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';
}
}

View 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 = 'Неизвестный браузер';
}
}
}

View 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>

View 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);
}
}

View 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
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View 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),
};

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

16
ui/src/index.html Normal file
View 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
View 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));

View 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
View 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
View 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
View 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
View 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"
]
}