adde desktop-angular
16
desktop-angular/src/frontend/.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
desktop-angular/src/frontend/.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
|
4
desktop-angular/src/frontend/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||
"recommendations": ["angular.ng-template"]
|
||||
}
|
123
desktop-angular/src/frontend/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,123 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Debug Angular (Cursor)",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"url": "http://localhost:4200",
|
||||
"webRoot": "${workspaceFolder}",
|
||||
"sourceMaps": true,
|
||||
"preLaunchTask": "npm: start",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**",
|
||||
"${workspaceFolder}/node_modules/**"
|
||||
],
|
||||
"sourceMapPathOverrides": {
|
||||
"webpack:///./src/*": "${webRoot}/src/*",
|
||||
"webpack:///src/*": "${webRoot}/src/*",
|
||||
"webpack:///*": "*",
|
||||
"webpack:///./~/*": "${webRoot}/node_modules/*",
|
||||
"meteor://💻app/*": "${webRoot}/*"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Attach to Chrome (Cursor)",
|
||||
"type": "chrome",
|
||||
"request": "attach",
|
||||
"port": 9222,
|
||||
"webRoot": "${workspaceFolder}",
|
||||
"sourceMaps": true,
|
||||
"skipFiles": [
|
||||
"<node_internals>/**",
|
||||
"${workspaceFolder}/node_modules/**"
|
||||
],
|
||||
"sourceMapPathOverrides": {
|
||||
"webpack:///./src/*": "${webRoot}/src/*",
|
||||
"webpack:///src/*": "${webRoot}/src/*",
|
||||
"webpack:///*": "*",
|
||||
"webpack:///./~/*": "${webRoot}/node_modules/*",
|
||||
"meteor://💻app/*": "${webRoot}/*"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Debug Angular Application",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"url": "http://localhost:4200",
|
||||
"webRoot": "${workspaceFolder}",
|
||||
"sourceMaps": true,
|
||||
"sourceMapPathOverrides": {
|
||||
"webpack:/*": "${webRoot}/*",
|
||||
"/./*": "${webRoot}/*",
|
||||
"/src/*": "${webRoot}/src/*",
|
||||
"/*": "*",
|
||||
"/./~/*": "${webRoot}/node_modules/*"
|
||||
},
|
||||
"preLaunchTask": "npm: start",
|
||||
"userDataDir": "${workspaceFolder}/.vscode/chrome-debug-profile",
|
||||
"runtimeArgs": [
|
||||
"--disable-web-security",
|
||||
"--disable-features=VizDisplayCompositor",
|
||||
"--remote-debugging-port=9222"
|
||||
],
|
||||
"skipFiles": [
|
||||
"<node_internals>/**",
|
||||
"${workspaceFolder}/node_modules/**"
|
||||
],
|
||||
"trace": true
|
||||
},
|
||||
{
|
||||
"name": "Debug Angular (Simple)",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"url": "http://localhost:4200",
|
||||
"webRoot": "${workspaceFolder}",
|
||||
"sourceMaps": true,
|
||||
"preLaunchTask": "npm: start",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**",
|
||||
"${workspaceFolder}/node_modules/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Attach to Chrome",
|
||||
"type": "chrome",
|
||||
"request": "attach",
|
||||
"port": 9222,
|
||||
"webRoot": "${workspaceFolder}",
|
||||
"sourceMaps": true,
|
||||
"sourceMapPathOverrides": {
|
||||
"webpack:/*": "${webRoot}/*",
|
||||
"/./*": "${webRoot}/*",
|
||||
"/src/*": "${webRoot}/src/*",
|
||||
"/*": "*",
|
||||
"/./~/*": "${webRoot}/node_modules/**"
|
||||
},
|
||||
"skipFiles": [
|
||||
"<node_internals>/**",
|
||||
"${workspaceFolder}/node_modules/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Debug with Edge",
|
||||
"type": "msedge",
|
||||
"request": "launch",
|
||||
"url": "http://localhost:4200",
|
||||
"webRoot": "${workspaceFolder}",
|
||||
"sourceMaps": true,
|
||||
"preLaunchTask": "npm: start",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**",
|
||||
"${workspaceFolder}/node_modules/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ng test",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: test",
|
||||
"url": "http://localhost:9876/debug.html"
|
||||
}
|
||||
]
|
||||
}
|
49
desktop-angular/src/frontend/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"djlint.showInstallError": false,
|
||||
"typescript.preferences.includePackageJsonAutoImports": "on",
|
||||
"typescript.suggest.autoImports": true,
|
||||
"typescript.updateImportsOnFileMove.enabled": "always",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit",
|
||||
"source.organizeImports": "explicit"
|
||||
},
|
||||
"debug.javascript.autoAttachFilter": "disabled",
|
||||
"debug.javascript.terminalOptions": {
|
||||
"skipFiles": [
|
||||
"<node_internals>/**",
|
||||
"${workspaceFolder}/node_modules/**"
|
||||
]
|
||||
},
|
||||
"files.associations": {
|
||||
"*.ts": "typescript",
|
||||
"*.html": "html"
|
||||
},
|
||||
"emmet.includeLanguages": {
|
||||
"typescript": "html"
|
||||
},
|
||||
"debug.allowBreakpointsEverywhere": true,
|
||||
"debug.javascript.breakOnConditionalError": false,
|
||||
"debug.javascript.codelens.npmScripts": "never",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"workbench.colorCustomizations": {
|
||||
"activityBar.activeBackground": "#fa1b49",
|
||||
"activityBar.background": "#fa1b49",
|
||||
"activityBar.foreground": "#e7e7e7",
|
||||
"activityBar.inactiveForeground": "#e7e7e799",
|
||||
"activityBarBadge.background": "#155e02",
|
||||
"activityBarBadge.foreground": "#e7e7e7",
|
||||
"commandCenter.border": "#e7e7e799",
|
||||
"sash.hoverBorder": "#fa1b49",
|
||||
"statusBar.background": "#dd0531",
|
||||
"statusBar.foreground": "#e7e7e7",
|
||||
"statusBarItem.hoverBackground": "#fa1b49",
|
||||
"statusBarItem.remoteBackground": "#dd0531",
|
||||
"statusBarItem.remoteForeground": "#e7e7e7",
|
||||
"titleBar.activeBackground": "#dd0531",
|
||||
"titleBar.activeForeground": "#e7e7e7",
|
||||
"titleBar.inactiveBackground": "#dd053199",
|
||||
"titleBar.inactiveForeground": "#e7e7e799"
|
||||
},
|
||||
"peacock.color": "#dd0531",
|
||||
}
|
86
desktop-angular/src/frontend/.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "npm: start",
|
||||
"type": "npm",
|
||||
"script": "start",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "(.*?)"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation complete"
|
||||
}
|
||||
}
|
||||
},
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
"reveal": "always",
|
||||
"focus": false,
|
||||
"panel": "shared",
|
||||
"showReuseMessage": true,
|
||||
"clear": false
|
||||
},
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "npm: build",
|
||||
"type": "npm",
|
||||
"script": "build",
|
||||
"group": "build",
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
"reveal": "silent",
|
||||
"focus": false,
|
||||
"panel": "shared"
|
||||
},
|
||||
"problemMatcher": ["$tsc"]
|
||||
},
|
||||
{
|
||||
"label": "npm: test",
|
||||
"type": "npm",
|
||||
"script": "test",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "(.*?)"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation complete"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Start Chrome for Debugging",
|
||||
"type": "shell",
|
||||
"command": "google-chrome",
|
||||
"args": [
|
||||
"--remote-debugging-port=9222",
|
||||
"--user-data-dir=${workspaceFolder}/.vscode/chrome-debug-profile",
|
||||
"--disable-web-security",
|
||||
"--disable-features=VizDisplayCompositor"
|
||||
],
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
"reveal": "always",
|
||||
"focus": false,
|
||||
"panel": "shared"
|
||||
},
|
||||
"group": "build"
|
||||
}
|
||||
]
|
||||
}
|
69
desktop-angular/src/frontend/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
desktop-angular/src/frontend/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
desktop-angular/src/frontend/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
desktop-angular/src/frontend/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
desktop-angular/src/frontend/package-lock.json
generated
Normal file
48
desktop-angular/src/frontend/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"
|
||||
}
|
||||
}
|
18
desktop-angular/src/frontend/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'
|
||||
})
|
||||
],
|
||||
};
|
7
desktop-angular/src/frontend/src/app/app.routes.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { RootComponent } from './root.component';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: '', component: RootComponent },
|
||||
{ path: '**', redirectTo: '' },
|
||||
];
|
100
desktop-angular/src/frontend/src/app/ipc.service.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
api?: any;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class IpcService {
|
||||
private get api() {
|
||||
return (typeof window !== 'undefined' && window.api) ? window.api : null;
|
||||
}
|
||||
|
||||
async getConfig(key: string): Promise<any> {
|
||||
return this.api?.getConfig ? this.api.getConfig(key) : null;
|
||||
}
|
||||
|
||||
async setConfig(key: string, value: any): Promise<any> {
|
||||
return this.api?.setConfig ? this.api.setConfig(key, value) : null;
|
||||
}
|
||||
|
||||
async getAllConfig(): Promise<any> {
|
||||
return this.api?.getAllConfig ? this.api.getAllConfig() : {};
|
||||
}
|
||||
|
||||
async setAllConfig(cfg: any): Promise<any> {
|
||||
return this.api?.setAllConfig ? this.api.setAllConfig(cfg) : { ok: false };
|
||||
}
|
||||
|
||||
async openFile(): Promise<any> {
|
||||
return this.api?.openFile ? this.api.openFile() : { canceled: true };
|
||||
}
|
||||
|
||||
async saveAs(payload: any): Promise<any> {
|
||||
return this.api?.saveAs ? this.api.saveAs(payload) : { canceled: true };
|
||||
}
|
||||
|
||||
async saveSilent(payload: any): Promise<any> {
|
||||
return this.api?.saveSilent ? this.api.saveSilent(payload) : { canceled: true };
|
||||
}
|
||||
|
||||
async revealInFolder(p: string): Promise<any> {
|
||||
return this.api?.revealInFolder ? this.api.revealInFolder(p) : { ok: false };
|
||||
}
|
||||
|
||||
async localKnock(payload: any): Promise<any> {
|
||||
return this.api?.localKnock ? this.api.localKnock(payload) : { success: false };
|
||||
}
|
||||
|
||||
async getNetworkInterfaces(): Promise<any> {
|
||||
return this.api?.getNetworkInterfaces ? this.api.getNetworkInterfaces() : { success: false };
|
||||
}
|
||||
|
||||
async testConnection(payload: any): Promise<any> {
|
||||
return this.api?.testConnection ? this.api.testConnection(payload) : { success: false };
|
||||
}
|
||||
|
||||
async closeSettings(): Promise<any> {
|
||||
return this.api?.closeSettings ? this.api.closeSettings() : { ok: false };
|
||||
}
|
||||
|
||||
// Electron-native custom modal
|
||||
async showNativeModal(config: any): Promise<{ buttonId: string; buttonIndex: number; buttonLabel?: string }> {
|
||||
return this.api?.showNativeModal ? this.api.showNativeModal(config) : { buttonId: 'unavailable', buttonIndex: -1 } as any;
|
||||
}
|
||||
|
||||
// Custom file dialog
|
||||
async showCustomFileDialog(config: any): Promise<{ canceled: boolean; filePath?: string; content?: string }> {
|
||||
return this.api?.showCustomFileDialog ? this.api.showCustomFileDialog(config) : { canceled: true };
|
||||
}
|
||||
|
||||
// Custom save dialog
|
||||
async showCustomSaveDialog(config: any): Promise<{ canceled: boolean; filePath?: string }> {
|
||||
return this.api?.showCustomSaveDialog ? this.api.showCustomSaveDialog(config) : { canceled: true };
|
||||
}
|
||||
|
||||
// Config files management
|
||||
async listConfigFiles(): Promise<{files: string[]}> {
|
||||
return this.api?.listConfigFiles ? this.api.listConfigFiles() : {files: []};
|
||||
}
|
||||
|
||||
async loadConfigFile(fileName: string): Promise<{success: boolean, content?: string}> {
|
||||
return this.api?.loadConfigFile ? this.api.loadConfigFile(fileName) : {success: false};
|
||||
}
|
||||
|
||||
// App lifecycle
|
||||
async checkUnsavedChanges(): Promise<boolean> {
|
||||
return this.api?.checkUnsavedChanges ? this.api.checkUnsavedChanges() : false;
|
||||
}
|
||||
|
||||
// YAML dirty state sync
|
||||
async setYamlDirty(isDirty: boolean): Promise<void> {
|
||||
if (this.api?.setYamlDirty) {
|
||||
await this.api.setYamlDirty(isDirty);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
113
desktop-angular/src/frontend/src/app/knock.service.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import * as yaml from 'js-yaml';
|
||||
import { IpcService } from './ipc.service';
|
||||
|
||||
export interface KnockExecuteBody {
|
||||
targets?: string;
|
||||
delay?: string;
|
||||
verbose?: boolean;
|
||||
waitConnection?: boolean;
|
||||
gateway?: string;
|
||||
config_yaml?: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class KnockService {
|
||||
constructor(private http: HttpClient, private ipc: IpcService) {}
|
||||
|
||||
basicAuthHeader(password: string): Record<string, string> {
|
||||
const token = btoa(`knocker:${password || ''}`);
|
||||
return { Authorization: `Basic ${token}` };
|
||||
}
|
||||
|
||||
convertInlineToYaml(targetsStr: string, delay: string, waitConnection: boolean): string {
|
||||
const entries = (targetsStr || '').split(';').filter(Boolean);
|
||||
const config: any = {
|
||||
targets: entries.map(e => {
|
||||
const parts = e.split(':');
|
||||
const protocol = parts[0] || 'tcp';
|
||||
const host = parts[1] || '127.0.0.1';
|
||||
const port = parseInt(parts[2] || '22', 10);
|
||||
return { protocol, host, ports: [port], wait_connection: !!waitConnection };
|
||||
}),
|
||||
delay: delay || '1s'
|
||||
};
|
||||
return yaml.dump(config as any, { lineWidth: 120 });
|
||||
}
|
||||
|
||||
convertYamlToInline(yamlText: string): { targets: string; delay: string; waitConnection: boolean } {
|
||||
if (!yamlText.trim()) return { targets: 'tcp:127.0.0.1:22', delay: '1s', waitConnection: false };
|
||||
const config: any = yaml.load(yamlText) || {};
|
||||
const list: string[] = [];
|
||||
(config.targets || []).forEach((t: any) => {
|
||||
const protocol = t.protocol || 'tcp';
|
||||
const host = t.host || '127.0.0.1';
|
||||
const ports = t.ports || [t.port] || [22];
|
||||
(Array.isArray(ports) ? ports : [ports]).forEach((p: any) => list.push(`${protocol}:${host}:${p}`));
|
||||
});
|
||||
return {
|
||||
targets: list.join(';'),
|
||||
delay: config.delay || '1s',
|
||||
waitConnection: !!config.targets?.[0]?.wait_connection
|
||||
};
|
||||
}
|
||||
|
||||
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 '';
|
||||
}
|
||||
|
||||
patchYamlPath(text: string, newPath: string): string {
|
||||
try {
|
||||
const doc: any = text.trim() ? yaml.load(text) : {};
|
||||
if (doc && typeof doc === 'object') {
|
||||
doc.path = newPath || '';
|
||||
return yaml.dump(doc, { lineWidth: 120 });
|
||||
}
|
||||
} catch {}
|
||||
return text;
|
||||
}
|
||||
|
||||
isEncryptedYaml(text: string): boolean {
|
||||
return (text || '').trim().startsWith('ENCRYPTED:');
|
||||
}
|
||||
|
||||
async knockViaHttp(apiBase: string, password: string, body: KnockExecuteBody): Promise<Response> {
|
||||
const headers = { ...this.basicAuthHeader(password), 'Content-Type': 'application/json' };
|
||||
return fetch(`${apiBase}/knock-actions/execute`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
}
|
||||
|
||||
async encryptYaml(apiBase: string, password: string, content: string) {
|
||||
const headers = { ...this.basicAuthHeader(password), 'Content-Type': 'application/json' };
|
||||
const r = await fetch(`${apiBase}/knock-actions/encrypt`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ yaml: content })
|
||||
});
|
||||
return r.json();
|
||||
}
|
||||
|
||||
async decryptYaml(apiBase: string, password: string, encrypted: string) {
|
||||
const headers = { ...this.basicAuthHeader(password), 'Content-Type': 'application/json' };
|
||||
const r = await fetch(`${apiBase}/knock-actions/decrypt`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ encrypted })
|
||||
});
|
||||
return r.json();
|
||||
}
|
||||
|
||||
async localKnock(payload: any) {
|
||||
return this.ipc.localKnock(payload);
|
||||
}
|
||||
}
|
||||
|
||||
|
133
desktop-angular/src/frontend/src/app/modal.component.scss
Normal file
@@ -0,0 +1,133 @@
|
||||
/* Modal overlay - covers entire screen */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* Modal dialog container */
|
||||
.modal-dialog {
|
||||
background: #aa1c3a; /* Same as footer background */
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Modal header */
|
||||
.modal-header {
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Modal body */
|
||||
.modal-body {
|
||||
padding: 20px 24px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-message {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Modal footer - styled like the main app footer */
|
||||
.modal-footer {
|
||||
padding: 16px 24px 20px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* Modal buttons - styled like footer buttons */
|
||||
.modal-btn {
|
||||
background: #ffffff;
|
||||
color: #aa1c3a;
|
||||
border: 1px solid #ffffff;
|
||||
border-radius: 6px;
|
||||
height: 40px;
|
||||
padding: 0 16px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
min-width: 80px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-btn:hover {
|
||||
background: #f8e8ec;
|
||||
border-color: #f8e8ec;
|
||||
}
|
||||
|
||||
/* Button style variants */
|
||||
.modal-btn-primary {
|
||||
background: #ffffff;
|
||||
color: #aa1c3a;
|
||||
}
|
||||
|
||||
.modal-btn-primary:hover {
|
||||
background: #f8e8ec;
|
||||
border-color: #f8e8ec;
|
||||
}
|
||||
|
||||
.modal-btn-secondary {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #ffffff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.modal-btn-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.modal-btn-danger {
|
||||
background: #e53935;
|
||||
color: #fff;
|
||||
border: 1px solid #e53935;
|
||||
}
|
||||
|
||||
.modal-btn-danger:hover {
|
||||
background: #c62828;
|
||||
border-color: #c62828;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 600px) {
|
||||
.modal-dialog {
|
||||
width: 95%;
|
||||
margin: 20px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-btn {
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
73
desktop-angular/src/frontend/src/app/modal.component.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ModalService, ModalConfig, ModalButton } from './modal.service';
|
||||
import { Subscription } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-modal',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="modal-overlay" *ngIf="config" (click)="onOverlayClick($event)">
|
||||
<div class="modal-dialog" (click)="$event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">{{ config.title }}</h2>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="modal-message">{{ config.message }}</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
*ngFor="let button of config.buttons"
|
||||
class="modal-btn"
|
||||
[class]="getButtonClass(button)"
|
||||
(click)="onButtonClick(button)"
|
||||
>
|
||||
{{ button.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styleUrls: ['./modal.component.scss']
|
||||
})
|
||||
export class ModalComponent implements OnInit, OnDestroy {
|
||||
config: ModalConfig | null = null;
|
||||
private subscription: Subscription = new Subscription();
|
||||
|
||||
constructor(private modalService: ModalService) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.subscription.add(
|
||||
this.modalService.modal$.subscribe(config => {
|
||||
this.config = config;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.subscription.unsubscribe();
|
||||
}
|
||||
|
||||
onButtonClick(button: ModalButton): void {
|
||||
this.modalService.onButtonClick(button.id, button.label);
|
||||
}
|
||||
|
||||
onOverlayClick(event: Event): void {
|
||||
// Close modal when clicking overlay (optional behavior)
|
||||
// this.modalService.hide();
|
||||
}
|
||||
|
||||
getButtonClass(button: ModalButton): string {
|
||||
const baseClass = 'modal-btn';
|
||||
switch (button.style) {
|
||||
case 'primary':
|
||||
return `${baseClass} modal-btn-primary`;
|
||||
case 'danger':
|
||||
return `${baseClass} modal-btn-danger`;
|
||||
case 'secondary':
|
||||
default:
|
||||
return `${baseClass} modal-btn-secondary`;
|
||||
}
|
||||
}
|
||||
}
|
88
desktop-angular/src/frontend/src/app/modal.service.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { BehaviorSubject, Observable } from 'rxjs';
|
||||
|
||||
export interface ModalButton {
|
||||
id: string;
|
||||
label: string;
|
||||
style?: 'primary' | 'secondary' | 'danger';
|
||||
}
|
||||
|
||||
export interface ModalConfig {
|
||||
title: string;
|
||||
message: string;
|
||||
buttons: ModalButton[];
|
||||
}
|
||||
|
||||
export interface ModalResult {
|
||||
buttonId: string;
|
||||
buttonLabel: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ModalService {
|
||||
private modalSubject = new BehaviorSubject<ModalConfig | null>(null);
|
||||
private resultSubject = new BehaviorSubject<ModalResult | null>(null);
|
||||
|
||||
public modal$: Observable<ModalConfig | null> = this.modalSubject.asObservable();
|
||||
public result$: Observable<ModalResult | null> = this.resultSubject.asObservable();
|
||||
|
||||
show(config: ModalConfig): Promise<ModalResult> {
|
||||
return new Promise((resolve) => {
|
||||
this.modalSubject.next(config);
|
||||
|
||||
const subscription = this.result$.subscribe(result => {
|
||||
if (result) {
|
||||
subscription.unsubscribe();
|
||||
this.hide();
|
||||
resolve(result);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
this.modalSubject.next(null);
|
||||
}
|
||||
|
||||
onButtonClick(buttonId: string, buttonLabel: string): void {
|
||||
this.resultSubject.next({ buttonId, buttonLabel });
|
||||
}
|
||||
|
||||
// Convenience methods for common dialog types
|
||||
async showConfirm(title: string, message: string): Promise<boolean> {
|
||||
const result = await this.show({
|
||||
title,
|
||||
message,
|
||||
buttons: [
|
||||
{ id: 'cancel', label: 'Cancel', style: 'secondary' },
|
||||
{ id: 'confirm', label: 'Confirm', style: 'primary' }
|
||||
]
|
||||
});
|
||||
return result.buttonId === 'confirm';
|
||||
}
|
||||
|
||||
async showYesNoCancel(title: string, message: string): Promise<'yes' | 'no' | 'cancel'> {
|
||||
const result = await this.show({
|
||||
title,
|
||||
message,
|
||||
buttons: [
|
||||
{ id: 'yes', label: 'Yes', style: 'primary' },
|
||||
{ id: 'no', label: 'No', style: 'secondary' },
|
||||
{ id: 'cancel', label: 'Cancel', style: 'secondary' }
|
||||
]
|
||||
});
|
||||
return result.buttonId as 'yes' | 'no' | 'cancel';
|
||||
}
|
||||
|
||||
async showInfo(title: string, message: string): Promise<void> {
|
||||
await this.show({
|
||||
title,
|
||||
message,
|
||||
buttons: [
|
||||
{ id: 'ok', label: 'OK', style: 'primary' }
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
233
desktop-angular/src/frontend/src/app/root.component.html
Normal file
@@ -0,0 +1,233 @@
|
||||
<!-- Synced with desktop/src/renderer/index.html structure -->
|
||||
<div class="app">
|
||||
<header style="background-color: #aa1c3a; color: #fff;">
|
||||
<h1 style="font-size: 2.5rem; margin-bottom: 1rem; ">
|
||||
Port Knocker - Desktop (powered by Angular)
|
||||
</h1>
|
||||
<div class="modes">
|
||||
<label
|
||||
><input
|
||||
type="radio"
|
||||
name="mode"
|
||||
value="inline"
|
||||
[checked]="mode === 'inline'"
|
||||
(change)="setMode('inline')"
|
||||
/>
|
||||
Inline</label
|
||||
>
|
||||
<label
|
||||
><input
|
||||
type="radio"
|
||||
name="mode"
|
||||
value="yaml"
|
||||
[checked]="mode === 'yaml'"
|
||||
(change)="setMode('yaml')"
|
||||
/>
|
||||
YAML</label
|
||||
>
|
||||
<label
|
||||
><input
|
||||
type="radio"
|
||||
name="mode"
|
||||
value="form"
|
||||
[checked]="mode === 'form'"
|
||||
(change)="setMode('form')"
|
||||
/>
|
||||
Form</label
|
||||
>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section id="constant-section" class="constant-mode-section">
|
||||
<div class="row">
|
||||
<label style="min-width: 100px">Api URL</label>
|
||||
<input
|
||||
id="apiUrl"
|
||||
type="text"
|
||||
placeholder="Введите api url"
|
||||
[(ngModel)]="apiBase"
|
||||
(change)="onApiUrlChange()"
|
||||
/>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label style="min-width: 100px">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Enter password"
|
||||
[(ngModel)]="password"
|
||||
/>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label style="min-width: 100px">Delay</label>
|
||||
<input
|
||||
id="delay"
|
||||
type="text"
|
||||
[(ngModel)]="delay"
|
||||
(change)="onDelayChange()"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="inline-section"
|
||||
class="mode-section"
|
||||
[class.hidden]="mode !== 'inline'"
|
||||
>
|
||||
<div class="row">
|
||||
<label style="min-width: 100px">Targets</label>
|
||||
<input
|
||||
id="targets"
|
||||
type="text"
|
||||
[(ngModel)]="targets"
|
||||
(change)="onTargetsChange()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label style="min-width: 100px">Gateway: </label>
|
||||
<input
|
||||
id="gateway"
|
||||
type="text"
|
||||
placeholder="optional"
|
||||
[(ngModel)]="gateway"
|
||||
(change)="onGatewayChange()"
|
||||
/>
|
||||
</div>
|
||||
<div class="row" style="margin-top: 1rem">
|
||||
<label
|
||||
><input id="verbose" type="checkbox" [(ngModel)]="verbose" /> Verbose</label
|
||||
>
|
||||
<label
|
||||
><input
|
||||
id="waitConnection"
|
||||
type="checkbox"
|
||||
[(ngModel)]="waitConnection"
|
||||
/>
|
||||
Wait connection</label
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="yaml-section"
|
||||
class="mode-section"
|
||||
[class.hidden]="mode !== 'yaml'"
|
||||
>
|
||||
<div class="toolbar">
|
||||
<button class="btn-primary" (click)="onNewConfiguration()">New Configuration</button>
|
||||
<button class="btn-warning" (click)="onOpenFile()">Open file</button>
|
||||
<button
|
||||
class="btn"
|
||||
[ngClass]="yamlDirty ? 'btn-primary' : 'btn-secondary'"
|
||||
(click)="onSaveCurrent()"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button class="btn-success" (click)="onSaveFile()">Save file as</button>
|
||||
<select class="config-select"
|
||||
[(ngModel)]="selectedConfigFile"
|
||||
(ngModelChange)="onConfigFileSelect($event)"
|
||||
[ngModelOptions]="{standalone: true}">
|
||||
<option value="">Select config file...</option>
|
||||
<option *ngFor="let file of configFiles" [value]="file">{{ file }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="yaml-editor-container">
|
||||
<textarea
|
||||
id="configYAML"
|
||||
placeholder="Paste YAML or open file"
|
||||
[(ngModel)]="configYAML"
|
||||
(ngModelChange)="onConfigYamlChange($event)"
|
||||
[class.has-unsaved-changes]="yamlDirty"
|
||||
></textarea>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="form-section"
|
||||
class="mode-section"
|
||||
[class.hidden]="mode !== 'form'"
|
||||
>
|
||||
<div
|
||||
id="targetsList"
|
||||
style="display: flex; flex-direction: column; gap: 0.5rem"
|
||||
>
|
||||
<div
|
||||
*ngFor="let t of formTargets; let i = index"
|
||||
class="row form-target-row"
|
||||
>
|
||||
<select [(ngModel)]="t.proto" class="target-proto">
|
||||
<option value="tcp">tcp</option>
|
||||
<option value="udp">udp</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="host"
|
||||
[(ngModel)]="t.host"
|
||||
class="target-host"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="port"
|
||||
[(ngModel)]="t.port"
|
||||
class="target-port"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="gateway (optional)"
|
||||
[(ngModel)]="t.gateway"
|
||||
class="target-gateway"
|
||||
/>
|
||||
<button class="btn-danger" (click)="removeFormTarget(i)">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" style="margin-top: 0.5rem">
|
||||
<button id="addTarget" class="btn-primary" (click)="addFormTarget()">
|
||||
Add target
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Test Buttons -->
|
||||
<div class="row" *ngIf="false" style="margin-top: 1rem; gap: 8px; flex-wrap: wrap;">
|
||||
<button class="btn-primary" (click)="showCustomModal()">Custom Modal</button>
|
||||
<button class="btn-primary" (click)="showConfirmDialog()">Confirm Dialog</button>
|
||||
<button class="btn-primary" (click)="showYesNoCancelDialog()">Yes/No/Cancel</button>
|
||||
<button class="btn-primary" (click)="showInfoDialog()">Info Dialog</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer style="background-color: #aa1c3a; color: #fff;">
|
||||
<div class="row" style="width: 100%; margin-top: 1rem">
|
||||
<button class="footer-btn" style="font-size: 1.5rem; width: 100%" (click)="onExecute()">Execute</button>
|
||||
</div>
|
||||
<div
|
||||
class="row"
|
||||
[class.hidden]="mode !== 'yaml'"
|
||||
id="encrypt-decrypt-row"
|
||||
style="width: 100%; margin-top: 1rem"
|
||||
>
|
||||
<button class="footer-btn" style="width: 50%" (click)="onEncrypt()">Encrypt</button>
|
||||
<button class="footer-btn" style="width: 50%" (click)="onDecrypt()">Decrypt</button>
|
||||
</div>
|
||||
<div class="row" style="width: 100%; margin-top: 1rem">
|
||||
<span
|
||||
[class.errorStatus]="
|
||||
status.toLowerCase().includes('error') ||
|
||||
status.toLowerCase().includes('ошибка')
|
||||
"
|
||||
[class.successStatus]="
|
||||
status.toLowerCase().includes('success') ||
|
||||
status.toLowerCase().includes('успех')
|
||||
"
|
||||
id="status"
|
||||
>{{ status }}</span
|
||||
>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Modal Dialog Component -->
|
||||
<app-modal></app-modal>
|
||||
</div>
|
293
desktop-angular/src/frontend/src/app/root.component.scss
Normal file
@@ -0,0 +1,293 @@
|
||||
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 */
|
||||
}
|
||||
|
||||
/* Unified control look for form fields (IP, protocol, port, etc.) */
|
||||
.row input[type="text"],
|
||||
.row input[type="number"],
|
||||
.row input[type="password"],
|
||||
.row select {
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #c7c7c7;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
color: #222;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.row input[type="text"]:focus,
|
||||
.row input[type="number"]:focus,
|
||||
.row input[type="password"]:focus,
|
||||
.row select:focus {
|
||||
border-color: #1976d2;
|
||||
box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.15);
|
||||
}
|
||||
|
||||
/* Align items nicely inside rows */
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Make each form target row occupy full width and distribute fields */
|
||||
.form-target-row {
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-target-row .target-proto {
|
||||
flex: 0 0 92px; /* select width */
|
||||
}
|
||||
|
||||
.form-target-row .target-host {
|
||||
flex: 1 1 240px;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.form-target-row .target-port {
|
||||
flex: 0 0 110px;
|
||||
}
|
||||
|
||||
.form-target-row .target-gateway {
|
||||
flex: 0 1 260px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.form-target-row .btn-danger {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #e53935;
|
||||
color: #fff;
|
||||
border: 1px solid #e53935;
|
||||
border-radius: 6px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c62828;
|
||||
border-color: #c62828;
|
||||
}
|
||||
|
||||
/* Primary (blue) button */
|
||||
.btn-primary {
|
||||
background: #1976d2;
|
||||
color: #fff;
|
||||
border: 1px solid #1976d2;
|
||||
border-radius: 6px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #1565c0;
|
||||
border-color: #1565c0;
|
||||
}
|
||||
|
||||
/* Secondary button style */
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: #fff;
|
||||
border: 1px solid #6c757d;
|
||||
border-radius: 6px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #5a6268;
|
||||
border-color: #5a6268;
|
||||
}
|
||||
|
||||
/* Success button style */
|
||||
.btn-success {
|
||||
background: #28a745;
|
||||
color: #0a0a0a;
|
||||
border: 1px solid #28a745;
|
||||
border-radius: 6px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: #218838;
|
||||
border-color: #218838;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: #ffc107;
|
||||
color: #0a0a0a;
|
||||
border: 1px solid #ffc107;
|
||||
border-radius: 6px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background: #ff9800;
|
||||
border-color: #218838;
|
||||
}
|
||||
|
||||
/* Config file select dropdown */
|
||||
.config-select {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
margin-left: 12px;
|
||||
min-width: 200px;
|
||||
cursor: pointer;
|
||||
height: 36px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #aa1c3a;
|
||||
box-shadow: 0 0 0 2px rgba(170, 28, 58, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
/* Footer buttons matching footer background */
|
||||
.footer-btn {
|
||||
background: #ffffff;
|
||||
color: #aa1c3a;
|
||||
border: 1px solid #ffffff;
|
||||
border-radius: 6px;
|
||||
height: 40px;
|
||||
padding: 0 14px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.footer-btn:hover {
|
||||
background: #f8e8ec;
|
||||
border-color: #f8e8ec;
|
||||
}
|
||||
|
||||
.errorStatus {
|
||||
color: #e53935 !important;
|
||||
background-color: #ffe6e6 !important;
|
||||
border: 1px solid #e53935 !important;
|
||||
border-radius: 4px !important;
|
||||
padding: 8px 12px !important;
|
||||
font-weight: bold !important;
|
||||
font-size: 14px !important;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1) !important;
|
||||
display: inline-block !important;
|
||||
margin-top: 10px !important;
|
||||
animation: shake 0.3s ease-in-out 0s 1 !important;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0% { transform: translateX(0); }
|
||||
25% { transform: translateX(-5px); }
|
||||
50% { transform: translateX(5px); }
|
||||
75% { transform: translateX(-5px); }
|
||||
100% { transform: translateX(0); }
|
||||
}
|
||||
|
||||
.successStatus {
|
||||
color: #2e7d32!important;
|
||||
background-color: #e6ffe6!important;
|
||||
border: 1px solid #2e7d32!important;
|
||||
border-radius: 4px!important;
|
||||
padding: 8px 12px!important;
|
||||
font-weight: bold!important;
|
||||
font-size: 14px!important;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1)!important;
|
||||
display: inline-block!important;
|
||||
margin-top: 10px!important;
|
||||
animation: shake 0.3s ease-in-out 0s 1!important;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0% { transform: translateX(0); }
|
||||
25% { transform: translateX(-5px); }
|
||||
50% { transform: translateX(5px); }
|
||||
75% { transform: translateX(-5px); }
|
||||
100% { transform: translateX(0); }
|
||||
}
|
||||
|
||||
/* YAML Editor Status Indicator */
|
||||
.yaml-editor-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.yaml-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 8px;
|
||||
background-color: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
color: #856404;
|
||||
animation: fadeIn 0.3s ease-in;
|
||||
}
|
||||
|
||||
.yaml-status.unsaved {
|
||||
background-color: #f8d7da;
|
||||
border-color: #f5c6cb;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
font-size: 16px;
|
||||
color: #dc3545;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
textarea.has-unsaved-changes {
|
||||
border-color: #dc3545;
|
||||
box-shadow: 0 0 0 2px rgba(220, 53, 69, 0.15);
|
||||
background-color: #fff5f5 !important;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
100% { opacity: 1; }
|
||||
}
|
767
desktop-angular/src/frontend/src/app/root.component.ts
Normal file
@@ -0,0 +1,767 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnInit, ChangeDetectorRef, NgZone } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { IpcService } from './ipc.service';
|
||||
import { KnockService } from './knock.service';
|
||||
import { ModalService } from './modal.service';
|
||||
import { ModalComponent } from './modal.component';
|
||||
import * as yaml from 'js-yaml';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, ModalComponent],
|
||||
templateUrl: './root.component.html',
|
||||
styleUrls: ['./root.component.scss'],
|
||||
})
|
||||
export class RootComponent implements OnInit {
|
||||
mode: 'inline' | 'yaml' | 'form' = 'inline';
|
||||
apiBase = 'http://localhost:8080/api/v1';
|
||||
password = '';
|
||||
delay = '1s';
|
||||
targets = 'tcp:127.0.0.1:22';
|
||||
gateway = '';
|
||||
verbose = true;
|
||||
waitConnection = false;
|
||||
configYAML = '';
|
||||
status = '';
|
||||
formTargets: {
|
||||
proto: 'tcp' | 'udp';
|
||||
host: string;
|
||||
port: number;
|
||||
gateway?: string;
|
||||
}[] = [];
|
||||
|
||||
// Config files management
|
||||
configFiles: string[] = [];
|
||||
selectedConfigFile: string = '';
|
||||
previousSelectedConfigFile: string = '';
|
||||
yamlDirty: boolean = false;
|
||||
private suppressYamlDirty: boolean = false;
|
||||
|
||||
constructor(
|
||||
private ipc: IpcService,
|
||||
private knock: KnockService,
|
||||
private modal: ModalService,
|
||||
private cdr: ChangeDetectorRef,
|
||||
private ngZone: NgZone
|
||||
) {
|
||||
this.loadConfigFiles('', '');
|
||||
|
||||
// Add beforeunload event listener to prevent closing with unsaved changes
|
||||
// if (typeof window !== 'undefined') {
|
||||
// window.addEventListener('beforeunload', (e: BeforeUnloadEvent) => {
|
||||
// console.log('beforeunload event triggered, yamlDirty:', this.yamlDirty);
|
||||
|
||||
// if (this.yamlDirty) {
|
||||
// console.log('Preventing close due to unsaved changes');
|
||||
// this.showCloseConfirmationDialog().then((result: any) => {
|
||||
// if (result === 'save') {
|
||||
// console.log('Saving changes');
|
||||
// this.onSaveCurrent().then((result: any) => {
|
||||
// if (result) {
|
||||
// console.log('Changes saved');
|
||||
// } else {
|
||||
// console.log('Changes not saved');
|
||||
// }
|
||||
// });
|
||||
// } else if (result === 'discard') {
|
||||
// return undefined;
|
||||
// } else {
|
||||
// return undefined;
|
||||
// }
|
||||
// }).catch((error: any) => {
|
||||
// console.error('Error showing close confirmation dialog:', error);
|
||||
// return undefined;
|
||||
// });
|
||||
|
||||
// e.preventDefault();
|
||||
// e.returnValue = 'У вас есть несохранённые изменения. Вы уверены, что хотите покинуть страницу?'; // Chrome requires returnValue to be set
|
||||
// return 'У вас есть несохранённые изменения. Вы уверены, что хотите покинуть страницу?'; // For older browsers
|
||||
// }
|
||||
// return undefined;
|
||||
// });
|
||||
// }
|
||||
|
||||
}
|
||||
|
||||
async loadConfigFiles(fileName?: string, configsPath?: string) {
|
||||
try {
|
||||
const result = await this.ipc.listConfigFiles();
|
||||
this.configFiles = result.files || [];
|
||||
// console.log('Config files loaded:', this.configFiles);
|
||||
|
||||
if (fileName) {
|
||||
// Принудительно обновляем DOM и устанавливаем selectedConfigFile
|
||||
this.cdr.detectChanges();
|
||||
this.selectedConfigFile = fileName;
|
||||
// console.log('Setting selectedConfigFile to:', fileName);
|
||||
this.cdr.detectChanges();
|
||||
this.previousSelectedConfigFile = fileName;
|
||||
await this.setYamlDirty(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading config files:', error);
|
||||
this.configFiles = [];
|
||||
}
|
||||
}
|
||||
|
||||
async onConfigYamlChange(_: string) {
|
||||
if (this.suppressYamlDirty) return;
|
||||
console.log('YAML content changed, setting yamlDirty = true');
|
||||
await this.setYamlDirty(true);
|
||||
}
|
||||
|
||||
// Method to check unsaved changes (called via IPC)
|
||||
checkUnsavedChanges(): boolean {
|
||||
console.log('checkUnsavedChanges called via IPC, yamlDirty:', this.yamlDirty);
|
||||
return this.yamlDirty;
|
||||
}
|
||||
|
||||
// Helper method to update yamlDirty state
|
||||
private async setYamlDirty(value: boolean) {
|
||||
this.yamlDirty = value;
|
||||
(window as any).yamlDirty = value;
|
||||
|
||||
// Sync with main process
|
||||
try {
|
||||
await this.ipc.setYamlDirty(value);
|
||||
console.log('YAML dirty state synced with main process:', value);
|
||||
} catch (error) {
|
||||
console.error('Error syncing yamlDirty state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate YAML content
|
||||
private validateYaml(yamlContent: string): { isValid: boolean; error?: string } {
|
||||
if (!yamlContent || yamlContent.trim() === '') {
|
||||
return { isValid: false, error: 'YAML контент не может быть пустым' };
|
||||
}
|
||||
|
||||
try {
|
||||
yaml.load(yamlContent);
|
||||
return { isValid: true };
|
||||
} catch (error: any) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `Ошибка в YAML: ${error.message || 'Неизвестная ошибка'}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Show confirmation dialog for closing with unsaved changes
|
||||
async showCloseConfirmationDialog(): Promise<'save' | 'discard' | 'cancel'> {
|
||||
const result = await this.modal.showYesNoCancel(
|
||||
'Несохранённые изменения',
|
||||
'У вас есть несохранённые изменения в конфигурации. Хотите сохранить?'
|
||||
);
|
||||
|
||||
if (result === 'yes') {
|
||||
return 'save';
|
||||
} else if (result === 'no') {
|
||||
return 'discard';
|
||||
} else {
|
||||
return 'cancel';
|
||||
}
|
||||
}
|
||||
|
||||
async onConfigFileSelect(fileName: string) {
|
||||
if (!fileName) {
|
||||
this.selectedConfigFile = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const revertSelection = () => {
|
||||
// вернуть обратно визуально
|
||||
const prev = this.previousSelectedConfigFile || '';
|
||||
if (this.selectedConfigFile !== prev) {
|
||||
this.selectedConfigFile = prev;
|
||||
}
|
||||
// Для надёжности: небольшой таймер в зоне Angular
|
||||
this.ngZone.run(() =>
|
||||
setTimeout(() => {
|
||||
if (this.selectedConfigFile !== prev) this.selectedConfigFile = prev;
|
||||
this.cdr.detectChanges();
|
||||
}, 50)
|
||||
);
|
||||
};
|
||||
|
||||
if (this.yamlDirty) {
|
||||
const choice = await this.modal.showYesNoCancel(
|
||||
'Unsaved changes',
|
||||
'You have unsaved changes. Save them before switching configuration?'
|
||||
);
|
||||
|
||||
if (choice === 'yes') {
|
||||
const beforeSave =
|
||||
this.previousSelectedConfigFile || this.selectedConfigFile;
|
||||
// Silent save to current file (if known) without dialogs
|
||||
const currentName =
|
||||
this.previousSelectedConfigFile || this.selectedConfigFile || '';
|
||||
let savedOk = false;
|
||||
if (currentName) {
|
||||
const r = await this.ipc.saveSilent({
|
||||
fileName: currentName,
|
||||
content: this.configYAML,
|
||||
});
|
||||
savedOk = !!(r && r.canceled === false && r.filePath);
|
||||
} else {
|
||||
// Fallback: use regular Save As
|
||||
savedOk = await this.onSaveFile();
|
||||
}
|
||||
// onSaveFile may early return; proceed only if not canceled
|
||||
if (!savedOk) {
|
||||
revertSelection();
|
||||
return;
|
||||
}
|
||||
// После сохранения переключаемся
|
||||
try {
|
||||
const result = await this.ipc.loadConfigFile(fileName);
|
||||
if (result.success && result.content) {
|
||||
this.configYAML = result.content;
|
||||
this.selectedConfigFile = fileName;
|
||||
this.previousSelectedConfigFile = fileName;
|
||||
await this.setYamlDirty(false);
|
||||
await this.loadConfigFiles(fileName);
|
||||
} else {
|
||||
await this.modal.showInfo(
|
||||
'Error',
|
||||
`Failed to load file: ${fileName}`
|
||||
);
|
||||
// вернуть предыдущее значение
|
||||
this.selectedConfigFile = beforeSave;
|
||||
this.previousSelectedConfigFile = beforeSave;
|
||||
console.log(
|
||||
'Revert selection - selectedConfigFile:',
|
||||
this.selectedConfigFile
|
||||
);
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading config file:', error);
|
||||
await this.modal.showInfo('Error', `Error loading file: ${fileName}`);
|
||||
revertSelection();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// no или cancel -> вернёмся к прежнему значению и выйдем
|
||||
revertSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
// Не было изменений — просто загружаем
|
||||
try {
|
||||
const result = await this.ipc.loadConfigFile(fileName);
|
||||
if (result.success && result.content) {
|
||||
this.suppressYamlDirty = true;
|
||||
this.configYAML = result.content;
|
||||
this.suppressYamlDirty = false;
|
||||
this.selectedConfigFile = fileName;
|
||||
this.previousSelectedConfigFile = fileName;
|
||||
await this.setYamlDirty(false);
|
||||
await this.loadConfigFiles(fileName);
|
||||
} else {
|
||||
await this.modal.showInfo('Error', `Failed to load file: ${fileName}`);
|
||||
revertSelection();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading config file:', error);
|
||||
await this.modal.showInfo('Error', `Error loading file: ${fileName}`);
|
||||
revertSelection();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.ipc
|
||||
.getConfig('apiBase')
|
||||
.then((v) => {
|
||||
if (typeof v === 'string' && v.trim()) this.apiBase = v;
|
||||
})
|
||||
.catch(() => {});
|
||||
this.ipc
|
||||
.getConfig('gateway')
|
||||
.then((v) => {
|
||||
if (typeof v === 'string') this.gateway = v;
|
||||
})
|
||||
.catch(() => {});
|
||||
this.ipc
|
||||
.getConfig('inlineTargets')
|
||||
.then((v) => {
|
||||
if (typeof v === 'string') this.targets = v;
|
||||
})
|
||||
.catch(() => {});
|
||||
this.ipc
|
||||
.getConfig('delay')
|
||||
.then((v) => {
|
||||
if (typeof v === 'string') this.delay = v;
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
setMode(m: 'inline' | 'yaml' | 'form') {
|
||||
this.mode = m;
|
||||
}
|
||||
|
||||
addFormTarget() {
|
||||
this.formTargets.push({
|
||||
proto: 'tcp',
|
||||
host: '127.0.0.1',
|
||||
port: 22,
|
||||
gateway: '',
|
||||
});
|
||||
}
|
||||
|
||||
async removeFormTarget(idx: number) {
|
||||
if (idx < 0 || idx >= this.formTargets.length) return;
|
||||
|
||||
const confirmDeletion = await this.modal.showConfirm(
|
||||
'Confirm Deletion',
|
||||
'Are you sure you want to delete this target?'
|
||||
);
|
||||
|
||||
if (!confirmDeletion) return;
|
||||
|
||||
this.formTargets.splice(idx, 1);
|
||||
}
|
||||
|
||||
buildInlineFromForm(): string {
|
||||
return this.formTargets
|
||||
.map((t) => `${t.proto}:${(t.host || '').trim()}:${Number(t.port) || 0}`)
|
||||
.filter((s) => /^(tcp|udp):[^:]+:\d+$/.test(s))
|
||||
.join(';');
|
||||
}
|
||||
|
||||
async onApiUrlChange() {
|
||||
if (!this.apiBase?.trim()) return;
|
||||
try {
|
||||
await this.ipc.setConfig('apiBase', this.apiBase.trim());
|
||||
this.show('API URL сохранён');
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async onGatewayChange() {
|
||||
try {
|
||||
await this.ipc.setConfig('gateway', this.gateway || '');
|
||||
this.show('Gateway сохранён');
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async onTargetsChange() {
|
||||
try {
|
||||
await this.ipc.setConfig('inlineTargets', this.targets || '');
|
||||
this.show('inlineTargets сохранёны');
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async onDelayChange() {
|
||||
try {
|
||||
await this.ipc.setConfig('delay', this.delay || '');
|
||||
this.show('Задержка сохранёна');
|
||||
} catch {}
|
||||
}
|
||||
|
||||
toYamlFromInline() {
|
||||
this.configYAML = this.knock.convertInlineToYaml(
|
||||
this.targets,
|
||||
this.delay,
|
||||
this.waitConnection
|
||||
);
|
||||
}
|
||||
|
||||
fromYamlToInline() {
|
||||
const r = this.knock.convertYamlToInline(this.configYAML);
|
||||
this.targets = r.targets;
|
||||
this.delay = r.delay;
|
||||
this.waitConnection = r.waitConnection;
|
||||
}
|
||||
|
||||
onServerFilePathInput(newPath: string) {
|
||||
this.configYAML = this.knock.patchYamlPath(this.configYAML, newPath);
|
||||
}
|
||||
|
||||
async onOpenFile() {
|
||||
if (this.configYAML.trim() !== '') {
|
||||
const confirmNew = await this.modal.showConfirm(
|
||||
'Open saved configuration',
|
||||
'This will replace the current YAML configuration with saved configuration. Continue?'
|
||||
);
|
||||
if (!confirmNew) return;
|
||||
}
|
||||
const res = await this.ipc.openFile();
|
||||
if (res?.canceled || res.content === undefined) return;
|
||||
this.suppressYamlDirty = true;
|
||||
this.configYAML = res.content;
|
||||
this.suppressYamlDirty = false;
|
||||
this.yamlDirty = false;
|
||||
|
||||
// Update selected file and refresh file list
|
||||
if (res.filePath) {
|
||||
const fileName =
|
||||
res.filePath.split('/').pop() || res.filePath.split('\\').pop() || '';
|
||||
console.log('Open file - setting selectedConfigFile to:', fileName);
|
||||
await this.loadConfigFiles(fileName, res.filePath);
|
||||
}
|
||||
}
|
||||
|
||||
async onNewConfiguration() {
|
||||
if (this.configYAML.trim() !== '') {
|
||||
const confirmNew = await this.modal.showConfirm(
|
||||
'Create New Default Configuration',
|
||||
'This will replace the current YAML configuration with a new default configuration. Continue?'
|
||||
);
|
||||
if (!confirmNew) return;
|
||||
}
|
||||
|
||||
// Default configuration with 3 targets
|
||||
this.suppressYamlDirty = true;
|
||||
this.configYAML = `description: "Default configuration"
|
||||
targets:
|
||||
- protocol: tcp
|
||||
host: 192.168.1.100
|
||||
ports: [22]
|
||||
wait_connection: true
|
||||
gateway: ""
|
||||
|
||||
- protocol: udp
|
||||
host: 192.168.1.101
|
||||
ports: [53, 123]
|
||||
wait_connection: false
|
||||
gateway: ""
|
||||
|
||||
- protocol: tcp
|
||||
host: 192.168.1.102
|
||||
ports: [80, 443]
|
||||
wait_connection: true
|
||||
gateway: ""
|
||||
|
||||
delay: 2s
|
||||
`;
|
||||
this.suppressYamlDirty = false;
|
||||
this.yamlDirty = false;
|
||||
this.show('Success: New configuration loaded');
|
||||
}
|
||||
|
||||
async onSaveFile(): Promise<boolean> {
|
||||
// Validate YAML content
|
||||
const validation = this.validateYaml(this.configYAML);
|
||||
if (!validation.isValid) {
|
||||
this.show(`Error: ${validation.error}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const confirmSave = await this.modal.showConfirm(
|
||||
'Confirm action to save Configuration',
|
||||
'Are you sure you want to save this configuration?'
|
||||
);
|
||||
if (!confirmSave) return false;
|
||||
|
||||
// const confirmSave = await this.ipc.showNativeModal({
|
||||
// title: 'Confirm Configuration Save',
|
||||
// message: 'Are you sure you want to save this configuration?',
|
||||
// buttons: [
|
||||
// { id: 'cancel', label: 'Cancel', style: 'secondary' },
|
||||
// { id: 'save', label: 'Save', style: 'primary' }
|
||||
// ],
|
||||
// colors: {
|
||||
// background: '#aa1c3a',
|
||||
// text: '#ffffff',
|
||||
// buttonBg: '#ffffff',
|
||||
// buttonText: '#aa1c3a',
|
||||
// secondaryBg: 'rgba(255,255,255,0.1)',
|
||||
// secondaryText: '#ffffff'
|
||||
// },
|
||||
// buttonStyles: {
|
||||
// save: { bg: '#e53935', text: '#fff' },
|
||||
// cancel: { bg: 'rgba(255,255,255,0.1)', text: '#ffffff' }
|
||||
// }
|
||||
// });
|
||||
|
||||
// if (confirmSave.buttonId !== 'save') return;
|
||||
|
||||
// Use current file name if available, otherwise suggest new name
|
||||
let suggested = '';
|
||||
if (this.selectedConfigFile === 'Select config file...') {
|
||||
suggested = this.knock.isEncryptedYaml(this.configYAML)
|
||||
? 'new_config.encrypted'
|
||||
: 'new_config.yaml';
|
||||
} else {
|
||||
suggested =
|
||||
this.selectedConfigFile ||
|
||||
(this.knock.isEncryptedYaml(this.configYAML)
|
||||
? 'new_config.encrypted'
|
||||
: 'new_config.yaml');
|
||||
}
|
||||
|
||||
console.log('Save file - selectedConfigFile:', this.selectedConfigFile);
|
||||
console.log('Save file - suggested name:', suggested);
|
||||
|
||||
const res = await this.ipc.saveAs({
|
||||
suggestedName: suggested,
|
||||
content: this.configYAML,
|
||||
});
|
||||
if (!res?.canceled && res.filePath) {
|
||||
this.show(`Success: Configuration saved\n${JSON.stringify(res)}`);
|
||||
// Update selected file and refresh file list
|
||||
const filePath = res.filePath;
|
||||
const fileName =
|
||||
filePath.split('/').pop() || filePath.split('\\').pop() || '';
|
||||
const folderPath = filePath.substring(0, filePath.lastIndexOf(fileName));
|
||||
console.log('Save file - folderPath:', folderPath);
|
||||
console.log('Save file - fileName:', fileName);
|
||||
|
||||
await this.loadConfigFiles(fileName, folderPath);
|
||||
this.yamlDirty = false;
|
||||
return true;
|
||||
}
|
||||
this.show('Error: Failed to save configuration');
|
||||
|
||||
// if (!res?.canceled && res.filePath) {
|
||||
// await this.ipc.revealInFolder(res.filePath);
|
||||
// }
|
||||
return false;
|
||||
}
|
||||
|
||||
async onSaveCurrent(): Promise<boolean> {
|
||||
const currentName =
|
||||
this.previousSelectedConfigFile || this.selectedConfigFile || '';
|
||||
|
||||
// Validate YAML content
|
||||
const validation = this.validateYaml(this.configYAML);
|
||||
if (!validation.isValid) {
|
||||
this.show(`Error: ${validation.error}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.yamlDirty) {
|
||||
const proceed = await this.modal.showConfirm(
|
||||
'No changes detected',
|
||||
'YAML has no unsaved changes. Save anyway?'
|
||||
);
|
||||
if (!proceed) return false;
|
||||
}
|
||||
if (!currentName) {
|
||||
// fallback to Save As
|
||||
const ok = await this.onSaveFile();
|
||||
if (ok) this.show('Success: Configuration saved');
|
||||
else this.show('Error: Failed to save configuration');
|
||||
return ok;
|
||||
}
|
||||
const r = await this.ipc.saveSilent({
|
||||
fileName: currentName,
|
||||
content: this.configYAML,
|
||||
});
|
||||
if (r && r.canceled === false && r.filePath) {
|
||||
const filePath = r.filePath;
|
||||
const fileName =
|
||||
filePath.split('/').pop() || filePath.split('\\').pop() || '';
|
||||
const folderPath = filePath.substring(0, filePath.lastIndexOf(fileName));
|
||||
this.yamlDirty = false;
|
||||
await this.loadConfigFiles(fileName, folderPath);
|
||||
this.show('Success: Configuration saved');
|
||||
return true;
|
||||
}
|
||||
this.show('Error: Failed to save configuration');
|
||||
return false;
|
||||
}
|
||||
|
||||
async onEncrypt() {
|
||||
const r = await this.knock.encryptYaml(
|
||||
this.apiBase,
|
||||
this.password,
|
||||
this.configYAML
|
||||
);
|
||||
const encrypted = r?.encrypted || '';
|
||||
this.configYAML = encrypted;
|
||||
await this.setYamlDirty(true);
|
||||
}
|
||||
|
||||
async onDecrypt() {
|
||||
if (!this.knock.isEncryptedYaml(this.configYAML)) return;
|
||||
const r = await this.knock.decryptYaml(
|
||||
this.apiBase,
|
||||
this.password,
|
||||
this.configYAML
|
||||
);
|
||||
const plain = r?.yaml || '';
|
||||
this.configYAML = plain;
|
||||
await this.setYamlDirty(true);
|
||||
}
|
||||
|
||||
async onExecute() {
|
||||
this.show('Выполнение…');
|
||||
const useLocalKnock =
|
||||
!this.apiBase ||
|
||||
this.apiBase.trim() === '' ||
|
||||
this.apiBase === 'internal' ||
|
||||
this.apiBase === '-' ||
|
||||
this.apiBase === 'embedded' ||
|
||||
this.apiBase === 'local';
|
||||
try {
|
||||
if (useLocalKnock) {
|
||||
let targetsList: string[] = [];
|
||||
if (this.mode === 'inline')
|
||||
targetsList = this.targets.split(';').filter((t) => t.trim());
|
||||
else if (this.mode === 'form')
|
||||
targetsList = this.buildInlineFromForm()
|
||||
.split(';')
|
||||
.filter((t) => t.trim());
|
||||
else if (this.mode === 'yaml') {
|
||||
const parsed = this.knock.convertYamlToInline(this.configYAML);
|
||||
targetsList = parsed.targets.split(';').filter(Boolean);
|
||||
this.delay = parsed.delay;
|
||||
}
|
||||
if (targetsList.length === 0) {
|
||||
this.show('Нет целей для простукивания');
|
||||
return;
|
||||
}
|
||||
let result;
|
||||
if (this.mode === 'form') {
|
||||
// perform per-target gateway if provided
|
||||
const results: any[] = [];
|
||||
for (let i = 0; i < this.formTargets.length; i++) {
|
||||
const t = this.formTargets[i];
|
||||
const targetStr = `${t.proto}:${(t.host || '').trim()}:${
|
||||
Number(t.port) || 0
|
||||
}`;
|
||||
if (!/^(tcp|udp):[^:]+:\d+$/.test(targetStr)) continue;
|
||||
// call one-by-one to allow different gateway per target
|
||||
// delay between calls will be respected by the main process per target list,
|
||||
// so we call with single-element targets to mimic sequence
|
||||
const singleResult = await this.ipc.localKnock({
|
||||
targets: [targetStr],
|
||||
delay: this.delay,
|
||||
verbose: this.verbose,
|
||||
gateway: (t.gateway || '').trim() || this.gateway?.trim() || '',
|
||||
});
|
||||
results.push(singleResult);
|
||||
}
|
||||
// emulate summary
|
||||
const okCount = results.filter((r) => r?.success).length;
|
||||
result = {
|
||||
success: okCount === results.length,
|
||||
summary: { total: results.length, successful: okCount },
|
||||
};
|
||||
} else {
|
||||
result = await this.ipc.localKnock({
|
||||
targets: targetsList,
|
||||
delay: this.delay,
|
||||
verbose: this.verbose,
|
||||
gateway: this.gateway?.trim() || '',
|
||||
});
|
||||
}
|
||||
if (result?.success) {
|
||||
const s = result.summary;
|
||||
this.show(
|
||||
`Успех: Локальное простукивание завершено: ${s.successful}/${s.total} успешно`
|
||||
);
|
||||
} else {
|
||||
this.show(
|
||||
`Ошибка локального простукивания: ${result?.error || 'unknown'}`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const body: any = {};
|
||||
if (this.mode === 'yaml') {
|
||||
// Prepare YAML: decrypt on-the-fly if needed, without mutating editor content
|
||||
let yamlToSend = this.configYAML;
|
||||
if (this.knock.isEncryptedYaml(yamlToSend)) {
|
||||
try {
|
||||
const dec = await this.knock.decryptYaml(
|
||||
this.apiBase,
|
||||
this.password,
|
||||
yamlToSend
|
||||
);
|
||||
yamlToSend = dec?.yaml || '';
|
||||
if (!yamlToSend) {
|
||||
this.show('Error: failed to decrypt configuration');
|
||||
return;
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.show(`Error: decryption failed - ${e?.message || e}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
body.config_yaml = yamlToSend;
|
||||
console.log('Execute - config_yaml:', yamlToSend);
|
||||
} else if (this.mode === 'inline') {
|
||||
body.targets = this.targets;
|
||||
body.delay = this.delay;
|
||||
body.verbose = this.verbose;
|
||||
body.waitConnection = this.waitConnection;
|
||||
body.gateway = this.gateway;
|
||||
console.log('Execute - targets:', this.targets);
|
||||
console.log('Execute - delay:', this.delay);
|
||||
console.log('Execute - verbose:', this.verbose);
|
||||
console.log('Execute - waitConnection:', this.waitConnection);
|
||||
console.log('Execute - gateway:', this.gateway);
|
||||
} else {
|
||||
body.targets = this.targets;
|
||||
body.delay = this.delay;
|
||||
body.verbose = this.verbose;
|
||||
body.waitConnection = this.waitConnection;
|
||||
}
|
||||
const res = await this.knock.knockViaHttp(
|
||||
this.apiBase,
|
||||
this.password,
|
||||
body
|
||||
);
|
||||
if ((res as any)?.ok) this.show('Успех: успешно простучали через API...');
|
||||
else this.show(`Ошибка API: ${(res as any).statusText}`);
|
||||
} catch (e: any) {
|
||||
this.show(`Ошибка: ${e?.message || e}`);
|
||||
}
|
||||
}
|
||||
|
||||
show(msg: string) {
|
||||
this.status = msg;
|
||||
setTimeout(() => {
|
||||
this.status = '';
|
||||
}, 5_000);
|
||||
}
|
||||
|
||||
// Example modal usage methods
|
||||
async showCustomModal() {
|
||||
const result = await this.modal.show({
|
||||
title: 'Custom Dialog',
|
||||
message:
|
||||
'This is a custom modal with 3 buttons. Which one will you choose?',
|
||||
buttons: [
|
||||
{ id: 'option1', label: 'Option 1', style: 'primary' },
|
||||
{ id: 'option2', label: 'Option 2', style: 'secondary' },
|
||||
{ id: 'cancel', label: 'Cancel', style: 'danger' },
|
||||
],
|
||||
});
|
||||
|
||||
this.show(`You clicked: ${result.buttonLabel} (ID: ${result.buttonId})`);
|
||||
}
|
||||
|
||||
async showConfirmDialog() {
|
||||
const confirmed = await this.modal.showConfirm(
|
||||
'Confirm Action',
|
||||
'Are you sure you want to proceed with this action?'
|
||||
);
|
||||
|
||||
this.show(confirmed ? 'Action confirmed!' : 'Action cancelled');
|
||||
}
|
||||
|
||||
async showYesNoCancelDialog() {
|
||||
const result = await this.modal.showYesNoCancel(
|
||||
'Save Changes',
|
||||
'Do you want to save your changes before closing?'
|
||||
);
|
||||
|
||||
this.show(`You chose: ${result}`);
|
||||
}
|
||||
|
||||
async showInfoDialog() {
|
||||
await this.modal.showInfo(
|
||||
'Information',
|
||||
'This is an informational dialog with just an OK button.'
|
||||
);
|
||||
|
||||
this.show('Info dialog closed');
|
||||
}
|
||||
}
|
@@ -0,0 +1,79 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { IpcService } from '../ipc.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
<div class="container">
|
||||
<div class="header">⚙️ Настройки приложения</div>
|
||||
<div class="content">
|
||||
<div class="field-group">
|
||||
<label for="configJson">Конфигурация (JSON формат):</label>
|
||||
<textarea id="configJson" [(ngModel)]="jsonText" placeholder="Загрузка конфигурации..."></textarea>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<button class="btn-secondary" (click)="onCancel()">Вернуться</button>
|
||||
<button class="btn-primary" (click)="onSave()">Сохранить</button>
|
||||
</div>
|
||||
<div id="status" class="status" [class.success]="statusType==='success'" [class.error]="statusType==='error'">{{status}}</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.container { max-width: 100%; background: white; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); overflow: hidden; }
|
||||
.header { background: #2c3e50; color: white; padding: 15px 20px; font-size: 18px; font-weight: 600; }
|
||||
.content { padding: 20px; }
|
||||
.field-group { margin-bottom: 20px; }
|
||||
label { display: block; margin-bottom: 8px; font-weight: 500; color: #333; }
|
||||
textarea { width: 100%; height: 300px; padding: 12px; border: 2px solid #ddd; border-radius: 6px; font-family: 'Courier New', monospace; font-size: 13px; line-height: 1.4; resize: vertical; box-sizing: border-box; }
|
||||
.buttons { display: flex; gap: 12px; justify-content: flex-end; margin-top: 20px; }
|
||||
.btn-primary { background: #3498db; color: white; padding: 10px 20px; border: none; border-radius: 6px; }
|
||||
.btn-secondary { background: #95a5a6; color: white; padding: 10px 20px; border: none; border-radius: 6px; }
|
||||
.status { margin-top: 10px; padding: 8px 12px; border-radius: 4px; font-size: 13px; display: none; }
|
||||
.status.success { display: block; background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
|
||||
.status.error { display: block; background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
|
||||
`]
|
||||
})
|
||||
export class SettingsComponent implements OnInit {
|
||||
jsonText = '';
|
||||
status = '';
|
||||
statusType: 'success' | 'error' | '' = '';
|
||||
|
||||
constructor(private ipc: IpcService) {}
|
||||
|
||||
async ngOnInit() {
|
||||
try {
|
||||
const cfg = await this.ipc.getAllConfig();
|
||||
this.jsonText = JSON.stringify(cfg || {}, null, 2);
|
||||
} catch {
|
||||
this.jsonText = '{}';
|
||||
this.show('Ошибка загрузки конфигурации', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async onSave() {
|
||||
try {
|
||||
const parsed = JSON.parse(this.jsonText);
|
||||
const res = await this.ipc.setAllConfig(parsed);
|
||||
if (res?.ok) this.show('Конфигурация успешно сохранена', 'success');
|
||||
else this.show(`Ошибка сохранения: ${res?.error || 'unknown'}`, 'error');
|
||||
} catch (e: any) {
|
||||
this.show(`Неверный JSON: ${e?.message || e}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async onCancel() {
|
||||
await this.ipc.closeSettings();
|
||||
}
|
||||
|
||||
private show(msg: string, type: 'success'|'error') {
|
||||
this.status = msg;
|
||||
this.statusType = type;
|
||||
}
|
||||
}
|
||||
|
||||
|
0
desktop-angular/src/frontend/src/assets/.gitkeep
Normal file
BIN
desktop-angular/src/frontend/src/assets/icons/icon-128x128.png
Normal file
After Width: | Height: | Size: 2.8 KiB |
BIN
desktop-angular/src/frontend/src/assets/icons/icon-144x144.png
Normal file
After Width: | Height: | Size: 3.0 KiB |
BIN
desktop-angular/src/frontend/src/assets/icons/icon-152x152.png
Normal file
After Width: | Height: | Size: 3.2 KiB |
BIN
desktop-angular/src/frontend/src/assets/icons/icon-192x192.png
Normal file
After Width: | Height: | Size: 4.2 KiB |
BIN
desktop-angular/src/frontend/src/assets/icons/icon-384x384.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
desktop-angular/src/frontend/src/assets/icons/icon-512x512.png
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
desktop-angular/src/frontend/src/assets/icons/icon-72x72.png
Normal file
After Width: | Height: | Size: 1.9 KiB |
BIN
desktop-angular/src/frontend/src/assets/icons/icon-96x96.png
Normal file
After Width: | Height: | Size: 2.3 KiB |
1
desktop-angular/src/frontend/src/assets/logo.txt
Normal file
@@ -0,0 +1 @@
|
||||
Port kicker
|
@@ -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
desktop-angular/src/frontend/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
desktop-angular/src/frontend/src/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
16
desktop-angular/src/frontend/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
desktop-angular/src/frontend/src/main.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { appConfig } from './app/app.config';
|
||||
import { RootComponent } from './app/root.component';
|
||||
|
||||
bootstrapApplication(RootComponent, appConfig)
|
||||
.catch((err) => console.error(err));
|
59
desktop-angular/src/frontend/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"
|
||||
}
|
||||
]
|
||||
}
|
96
desktop-angular/src/frontend/src/styles.scss
Normal file
@@ -0,0 +1,96 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
header,
|
||||
footer {
|
||||
padding: 12px 16px;
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.modes label {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.mode-section {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.constant-mode-section {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
height: 280px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #334155;
|
||||
background: #1f2937;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
#status {
|
||||
margin-left: 12px;
|
||||
color: #0ea5e9;
|
||||
}
|
||||
|
||||
#targetsList .target-row {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr 120px 1fr auto;
|
||||
gap: 8px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
#targetsList .remove {
|
||||
background: #7f1d1d;
|
||||
border-color: #7f1d1d;
|
||||
}
|
14
desktop-angular/src/frontend/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
desktop-angular/src/frontend/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
desktop-angular/src/frontend/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"
|
||||
]
|
||||
}
|